diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index b49ec7deb7..46e38fca27 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -48,8 +48,6 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), - OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH: enabledByExperimental("OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH"), - // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. get OPENCODE_DISABLE_PROJECT_CONFIG() { diff --git a/packages/opencode/src/mcp/tool-search.ts b/packages/opencode/src/mcp/tool-search.ts index 804e65c024..465dfc3dc8 100644 --- a/packages/opencode/src/mcp/tool-search.ts +++ b/packages/opencode/src/mcp/tool-search.ts @@ -1,10 +1,13 @@ import { jsonSchema, tool, type JSONSchema7, type Tool, type ToolExecutionOptions } from "ai" import fuzzysort from "fuzzysort" +import { Token } from "@opencode-ai/core/util/token" // Match Hermes defaults. OpenClaw independently uses the same maximum. const DEFAULT_SEARCH_LIMIT = 5 const MAX_SEARCH_LIMIT = 20 const MAX_SEARCH_DESCRIPTION = 400 +const SEARCH_THRESHOLD_TOKENS = 15_000 +const controls = new WeakSet() type Entry = { id: string @@ -14,6 +17,18 @@ type Entry = { tool: Tool } +export function shouldUse(tools: Record, schemas: Record) { + const catalog = Object.entries(tools) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([name, item]) => JSON.stringify({ name, description: item.description, inputSchema: schemas[name] })) + .join("\n") + return Token.estimate(catalog) > SEARCH_THRESHOLD_TOKENS +} + +export function isControl(item: Tool) { + return controls.has(item) +} + export function create(input: { tools: Record schemas: Record @@ -39,7 +54,7 @@ export function create(input: { if (entries.size === 0) return {} - return { + const result = { mcp_search: tool({ description: "Search connected MCP tools by capability. Returns matching tool IDs and short descriptions. Use mcp_describe to inspect a tool before calling it.", @@ -128,6 +143,8 @@ export function create(input: { }, }), } + for (const item of Object.values(result)) controls.add(item) + return result } function resolve(entries: Map, id: string) { diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 3de8d4e811..9d26a722f9 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -15,7 +15,7 @@ import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai" import type { Plugin } from "@/plugin" import { mergeDeep } from "remeda" import { Wildcard } from "@opencode-ai/core/util/wildcard" -import { Flag } from "@opencode-ai/core/flag/flag" +import { McpToolSearch } from "@/mcp/tool-search" const USER_AGENT = `opencode/${InstallationVersion}` @@ -200,11 +200,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre export function resolveTools(input: Pick) { const ruleset = Permission.merge(input.agent.permission, input.permission ?? []) const disabled = Permission.disabled(Object.keys(input.tools), ruleset) - const controls = new Set(["mcp_search", "mcp_describe", "mcp_call"]) - return Record.filter(input.tools, (_, key) => { + return Record.filter(input.tools, (item, key) => { if (input.user.tools?.[key] === false) return false if (!disabled.has(key)) return true - if (!Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH || !controls.has(key)) return false + if (!McpToolSearch.isControl(item)) return false return ruleset.findLast((rule) => Wildcard.match(key, rule.permission))?.permission === "*" }) } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 43a78fdc8e..9be48725ac 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -21,7 +21,7 @@ import { EffectBridge } from "@/effect/bridge" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" -import { Flag } from "@opencode-ai/core/flag/flag" +import { McpToolSearch } from "@/mcp/tool-search" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -384,6 +384,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const mcpTools: Record = {} const mcpSchemas: Record = {} + const mcpProviderSchemas: Record = {} for (const [key, item] of Object.entries(yield* mcp.tools())) { const execute = item.execute if (!execute) continue @@ -391,6 +392,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) mcpSchemas[key] = { ...schema, properties: schema.properties ?? {} } const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) + mcpProviderSchemas[key] = transformed item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => run.promise( @@ -486,12 +488,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { mcpTools[key] = item } - if (!Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH) { - Object.assign(tools, mcpTools) - return tools - } - - const { McpToolSearch } = yield* Effect.promise(() => import("@/mcp/tool-search")) const disabled = Permission.disabled( Object.keys(mcpTools), Permission.merge(input.agent.permission, input.session.permission ?? []), @@ -501,6 +497,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const searchable = Object.fromEntries( Object.entries(mcpTools).filter(([key]) => overrides?.[key] !== false && !disabled.has(key)), ) + const providerSchemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpProviderSchemas[key]])) + if (!McpToolSearch.shouldUse(searchable, providerSchemas)) { + Object.assign(tools, mcpTools) + return tools + } + const schemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpSchemas[key]])) const controls = McpToolSearch.create({ tools: searchable, diff --git a/packages/opencode/test/mcp/tool-search.test.ts b/packages/opencode/test/mcp/tool-search.test.ts index 46b95f3fee..7d902840a9 100644 --- a/packages/opencode/test/mcp/tool-search.test.ts +++ b/packages/opencode/test/mcp/tool-search.test.ts @@ -44,6 +44,22 @@ async function catalog() { } describe("MCP tool search", () => { + test("uses direct tools below the token threshold", async () => { + const tools = { + github_create_issue: target("github_create_issue", "Create an issue"), + } + const schemas = { github_create_issue: { type: "object", properties: {} } } satisfies Record + expect(McpToolSearch.shouldUse(tools, schemas)).toBe(false) + }) + + test("uses search above the token threshold", async () => { + const tools = { + large_catalog: target("large_catalog", "x".repeat(60_000)), + } + const schemas = { large_catalog: { type: "object", properties: {} } } satisfies Record + expect(McpToolSearch.shouldUse(tools, schemas)).toBe(true) + }) + test("exposes only the three stable control tools", async () => { expect(Object.keys(await catalog())).toEqual(["mcp_search", "mcp_describe", "mcp_call"]) }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 6b864f1961..a8b670f86f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -28,7 +28,7 @@ import { Session as SessionNs } from "@/session/session" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { Flag } from "@opencode-ai/core/flag/flag" +import { McpToolSearch } from "@/mcp/tool-search" type ConfigModel = NonNullable[string]["models"]>[string] @@ -177,73 +177,88 @@ describe("session.llm.hasToolCalls", () => { describe("session.llm.resolveTools", () => { test("keeps MCP search controls under a generic deny rule", () => { - const previous = Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH - Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH = true - try { - const agent = { - name: "test", - mode: "primary", - options: {}, - permission: [ - { permission: "*", pattern: "*", action: "deny" }, - { permission: "github_create_issue", pattern: "*", action: "allow" }, - ], - } satisfies Agent.Info - const user = { - id: MessageID.make("msg_mcp_tool_search"), - sessionID: SessionID.make("ses_mcp_tool_search"), - role: "user", - time: { created: Date.now() }, - agent: agent.name, - model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, - } satisfies SessionV1.User - const control = tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) - expect( - Object.keys( - LLMRequestPrep.resolveTools({ - tools: { mcp_search: control, mcp_describe: control, mcp_call: control }, - agent, - user, - }), - ), - ).toEqual(["mcp_search", "mcp_describe", "mcp_call"]) - } finally { - Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH = previous - } - }) - - test("honors explicit MCP search control denies", () => { - const previous = Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH - Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH = true - try { - const agent = { - name: "test", - mode: "primary", - options: {}, - permission: [ - { permission: "*", pattern: "*", action: "deny" }, - { permission: "mcp_*", pattern: "*", action: "deny" }, - ], - } satisfies Agent.Info - const user = { - id: MessageID.make("msg_mcp_tool_search_deny"), - sessionID: SessionID.make("ses_mcp_tool_search_deny"), - role: "user", - time: { created: Date.now() }, - agent: agent.name, - model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, - } satisfies SessionV1.User - const control = tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) - expect( + const controls = McpToolSearch.create({ + tools: { target: tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) }, + schemas: { target: { type: "object", properties: {} } }, + transformSchema: (schema) => schema, + }) + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "github_create_issue", pattern: "*", action: "allow" }, + ], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_mcp_tool_search"), + sessionID: SessionID.make("ses_mcp_tool_search"), + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + } satisfies SessionV1.User + expect( + Object.keys( LLMRequestPrep.resolveTools({ - tools: { mcp_search: control, mcp_describe: control, mcp_call: control }, + tools: controls, agent, user, }), - ).toEqual({}) - } finally { - Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH = previous - } + ), + ).toEqual(["mcp_search", "mcp_describe", "mcp_call"]) + }) + + test("honors explicit MCP search control denies", () => { + const controls = McpToolSearch.create({ + tools: { target: tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) }, + schemas: { target: { type: "object", properties: {} } }, + transformSchema: (schema) => schema, + }) + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "mcp_*", pattern: "*", action: "deny" }, + ], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_mcp_tool_search_deny"), + sessionID: SessionID.make("ses_mcp_tool_search_deny"), + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + } satisfies SessionV1.User + expect( + LLMRequestPrep.resolveTools({ + tools: controls, + agent, + user, + }), + ).toEqual({}) + }) + + test("does not exempt plugin tools that use MCP control names", () => { + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_mcp_plugin_tool"), + sessionID: SessionID.make("ses_mcp_plugin_tool"), + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + } satisfies SessionV1.User + const pluginTool = tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) + expect(LLMRequestPrep.resolveTools({ tools: { mcp_call: pluginTool }, agent, user })).toEqual({}) }) })