From 94b7d450c5d84f6a5012a3dc6768458a655138af Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 19:33:55 -0500 Subject: [PATCH 1/3] feat(opencode): add experimental MCP tool search --- packages/core/src/flag/flag.ts | 1 + packages/opencode/src/mcp/tool-search.ts | 148 ++++++++++++++++++ packages/opencode/src/session/llm/request.ts | 18 ++- packages/opencode/src/session/tools.ts | 32 +++- .../opencode/test/mcp/tool-search.test.ts | 122 +++++++++++++++ packages/opencode/test/session/llm.test.ts | 74 +++++++++ 6 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/mcp/tool-search.ts create mode 100644 packages/opencode/test/mcp/tool-search.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e..b49ec7deb7 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -48,6 +48,7 @@ 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. diff --git a/packages/opencode/src/mcp/tool-search.ts b/packages/opencode/src/mcp/tool-search.ts new file mode 100644 index 0000000000..804e65c024 --- /dev/null +++ b/packages/opencode/src/mcp/tool-search.ts @@ -0,0 +1,148 @@ +import { jsonSchema, tool, type JSONSchema7, type Tool, type ToolExecutionOptions } from "ai" +import fuzzysort from "fuzzysort" + +// Match Hermes defaults. OpenClaw independently uses the same maximum. +const DEFAULT_SEARCH_LIMIT = 5 +const MAX_SEARCH_LIMIT = 20 +const MAX_SEARCH_DESCRIPTION = 400 + +type Entry = { + id: string + description: string + parameters: string + schema: JSONSchema7 + tool: Tool +} + +export function create(input: { + tools: Record + schemas: Record + transformSchema: (schema: JSONSchema7) => JSONSchema7 +}): Record { + const entries = new Map( + Object.entries(input.tools) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([id, item]) => { + const schema = input.schemas[id] + return [ + id, + { + id, + description: item.description ?? "", + parameters: Object.keys(schema.properties ?? {}).join(" "), + schema, + tool: item, + }, + ] as const + }), + ) + + if (entries.size === 0) return {} + + return { + 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.", + inputSchema: jsonSchema<{ query: string; limit?: number }>( + input.transformSchema({ + type: "object", + properties: { + query: { type: "string", description: "Capability, tool name, or keywords to search for." }, + limit: { + type: "integer", + minimum: 1, + maximum: MAX_SEARCH_LIMIT, + description: `Maximum results to return (default ${DEFAULT_SEARCH_LIMIT}, maximum ${MAX_SEARCH_LIMIT}).`, + }, + }, + required: ["query"], + additionalProperties: false, + }), + ), + async execute(args: { query: string; limit?: number }) { + const query = args.query.trim() + if (!query) throw new Error("query must be a non-empty string") + const limit = Math.min(MAX_SEARCH_LIMIT, Math.max(1, Math.trunc(args.limit ?? DEFAULT_SEARCH_LIMIT))) + const matches = search([...entries.values()], query, limit) + return { + title: `MCP tools matching ${args.query}`, + metadata: { query: args.query, count: matches.length }, + output: JSON.stringify( + { + query, + totalAvailable: entries.size, + tools: matches.map((entry) => ({ + id: entry.id, + description: entry.description.slice(0, MAX_SEARCH_DESCRIPTION), + })), + }, + null, + 2, + ), + } + }, + }), + mcp_describe: tool({ + description: "Return the full description and input schema for one MCP tool found with mcp_search.", + inputSchema: jsonSchema<{ id: string }>( + input.transformSchema({ + type: "object", + properties: { + id: { type: "string", description: "Exact MCP tool ID returned by mcp_search." }, + }, + required: ["id"], + additionalProperties: false, + }), + ), + async execute(args: { id: string }) { + const entry = resolve(entries, args.id) + return { + title: entry.id, + metadata: { id: entry.id }, + output: JSON.stringify({ id: entry.id, description: entry.description, inputSchema: entry.schema }, null, 2), + } + }, + }), + mcp_call: tool({ + description: + "Call an MCP tool by exact ID. Inspect unfamiliar tools with mcp_describe first. The underlying MCP tool's permissions and lifecycle hooks still apply.", + inputSchema: jsonSchema<{ id: string; args?: Record }>( + input.transformSchema({ + type: "object", + properties: { + id: { type: "string", description: "Exact MCP tool ID returned by mcp_search." }, + args: { + type: "object", + description: "Arguments matching the input schema returned by mcp_describe.", + additionalProperties: true, + }, + }, + required: ["id"], + additionalProperties: false, + }), + ), + async execute(args: { id: string; args?: Record }, options: ToolExecutionOptions) { + const entry = resolve(entries, args.id) + if (!entry.tool.execute) throw new Error(`MCP tool "${entry.id}" is not executable`) + return entry.tool.execute(args.args ?? {}, options) + }, + }), + } +} + +function resolve(entries: Map, id: string) { + const requested = id.trim() + const entry = entries.get(requested) + if (entry) return entry + const suggestions = search([...entries.values()], requested.replaceAll(/[^a-zA-Z0-9]+/g, "_"), 3).map( + (item) => item.id, + ) + const hint = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "" + throw new Error(`Unknown MCP tool "${id}".${hint}`) +} + +function search(entries: Entry[], query: string, limit: number) { + return fuzzysort.go(query, entries, { keys: ["id", "description", "parameters"], limit }).map((item) => item.obj) +} + +export * as McpToolSearch from "./tool-search" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2785d98526..3de8d4e811 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -14,6 +14,8 @@ import { Effect, Record } from "effect" 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" const USER_AGENT = `opencode/${InstallationVersion}` @@ -195,12 +197,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre } }) -function resolveTools(input: Pick) { - const disabled = Permission.disabled( - Object.keys(input.tools), - Permission.merge(input.agent.permission, input.permission ?? []), - ) - return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k)) +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) => { + 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 + return ruleset.findLast((rule) => Wildcard.match(key, rule.permission))?.permission === "*" + }) } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 376ba8f2b8..43a78fdc8e 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -11,7 +11,7 @@ import { Truncate } from "@/tool/truncate" import { Plugin } from "@/plugin" import type { TaskPromptOps } from "@/tool/task" -import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" +import { type Tool as AITool, tool, jsonSchema, type JSONSchema7, type ToolExecutionOptions, asSchema } from "ai" import { Effect } from "effect" import { MessageV2 } from "./message-v2" import { Session } from "./session" @@ -21,6 +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" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -381,11 +382,14 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } + const mcpTools: Record = {} + const mcpSchemas: Record = {} for (const [key, item] of Object.entries(yield* mcp.tools())) { const execute = item.execute if (!execute) continue 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 ?? {} }) item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => @@ -479,9 +483,33 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { return output }), ) - tools[key] = item + 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 ?? []), + ) + const user = input.messages.findLast((message) => message.info.role === "user") + const overrides = user?.info.role === "user" ? user.info.tools : undefined + const searchable = Object.fromEntries( + Object.entries(mcpTools).filter(([key]) => overrides?.[key] !== false && !disabled.has(key)), + ) + const schemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpSchemas[key]])) + const controls = McpToolSearch.create({ + tools: searchable, + schemas, + transformSchema: (schema) => ProviderTransform.schema(input.model, schema), + }) + const collision = Object.keys(controls).find((key) => tools[key]) + if (collision) throw new Error(`Tool name reserved for MCP tool search: ${collision}`) + Object.assign(tools, controls) return tools }) diff --git a/packages/opencode/test/mcp/tool-search.test.ts b/packages/opencode/test/mcp/tool-search.test.ts new file mode 100644 index 0000000000..46b95f3fee --- /dev/null +++ b/packages/opencode/test/mcp/tool-search.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test" +import { jsonSchema, tool } from "ai" +import type { JSONSchema7 } from "@ai-sdk/provider" +import { McpToolSearch } from "../../src/mcp/tool-search" + +function target(name: string, description: string, properties: JSONSchema7["properties"] = {}) { + return tool({ + description, + inputSchema: jsonSchema({ + type: "object", + properties, + additionalProperties: false, + }), + async execute(args) { + return { title: name, metadata: { name }, output: JSON.stringify(args) } + }, + }) +} + +async function catalog() { + return McpToolSearch.create({ + tools: { + github_create_issue: target("github_create_issue", "Create an issue in a GitHub repository", { + title: { type: "string" }, + }), + playwright_take_screenshot: target("playwright_take_screenshot", "Capture the browser page", { + fullPage: { type: "boolean" }, + }), + }, + schemas: { + github_create_issue: { + type: "object", + properties: { title: { type: "string" } }, + additionalProperties: false, + }, + playwright_take_screenshot: { + type: "object", + properties: { fullPage: { type: "boolean" } }, + additionalProperties: false, + }, + }, + transformSchema: (schema) => schema, + }) +} + +describe("MCP tool search", () => { + test("exposes only the three stable control tools", async () => { + expect(Object.keys(await catalog())).toEqual(["mcp_search", "mcp_describe", "mcp_call"]) + }) + + test("searches names, descriptions, and parameter names", async () => { + const tools = await catalog() + const result = (await tools.mcp_search!.execute?.( + { query: "fullPage" }, + { toolCallId: "search", messages: [], abortSignal: new AbortController().signal }, + )) as { output: string } + expect(result.output).toContain("playwright_take_screenshot") + expect(result.output).not.toContain("github_create_issue") + }) + + test("fuzzy matches misspelled tool names", async () => { + const tools = await catalog() + const result = (await tools.mcp_search!.execute?.( + { query: "screeshot" }, + { toolCallId: "search", messages: [], abortSignal: new AbortController().signal }, + )) as { output: string } + expect(result.output).toContain("playwright_take_screenshot") + }) + + test("describes the full target schema", async () => { + const tools = await catalog() + const result = (await tools.mcp_describe!.execute?.( + { id: "github_create_issue" }, + { toolCallId: "describe", messages: [], abortSignal: new AbortController().signal }, + )) as { output: string } + expect(result.output).toContain('"title"') + expect(result.output).toContain('"type": "string"') + }) + + test("describes the original schema before provider transforms", async () => { + const tools = await McpToolSearch.create({ + tools: { search: target("search", "Search") }, + schemas: { + search: { + type: "object", + properties: { query: { type: "string", pattern: "^[a-z]+$" } }, + }, + }, + transformSchema: (schema) => schema, + }) + const result = (await tools.mcp_describe!.execute?.( + { id: "search" }, + { toolCallId: "describe", messages: [], abortSignal: new AbortController().signal }, + )) as { output: string } + expect(result.output).toContain('"pattern": "^[a-z]+$"') + }) + + test("calls the hidden target with structured arguments", async () => { + const tools = await catalog() + const result = (await tools.mcp_call!.execute?.( + { id: "github_create_issue", args: { title: "Cache bug" } }, + { toolCallId: "call", messages: [], abortSignal: new AbortController().signal }, + )) as { output: string } + expect(result.output).toBe('{"title":"Cache bug"}') + }) + + test("suggests but does not execute inexact tool names", async () => { + const tools = await McpToolSearch.create({ + tools: { + github___create_issue: target("github___create_issue", "Create an issue"), + }, + schemas: { github___create_issue: { type: "object", properties: {} } }, + transformSchema: (schema) => schema, + }) + expect( + tools.mcp_call!.execute?.( + { id: "GitHub/create-issue", args: { title: "Cache bug" } }, + { toolCallId: "call", messages: [], abortSignal: new AbortController().signal }, + ), + ).rejects.toThrow("Did you mean: github___create_issue?") + }) +}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 0c5dadaf17..6b864f1961 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -25,8 +25,10 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Permission } from "@/permission" import { LLMAISDK } from "@/session/llm/ai-sdk" 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" type ConfigModel = NonNullable[string]["models"]>[string] @@ -173,6 +175,78 @@ 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( + LLMRequestPrep.resolveTools({ + tools: { mcp_search: control, mcp_describe: control, mcp_call: control }, + agent, + user, + }), + ).toEqual({}) + } finally { + Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH = previous + } + }) +}) + describe("session.llm.ai-sdk adapter", () => { type AISDKAdapterEvent = Parameters[1] From bc189de0803fdcfe33318daae79442b03471a0a4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 19:44:38 -0500 Subject: [PATCH 2/3] refactor(opencode): activate MCP search by catalog size --- packages/core/src/flag/flag.ts | 2 - packages/opencode/src/mcp/tool-search.ts | 19 ++- packages/opencode/src/session/llm/request.ts | 7 +- packages/opencode/src/session/tools.ts | 16 +- .../opencode/test/mcp/tool-search.test.ts | 16 ++ packages/opencode/test/session/llm.test.ts | 143 ++++++++++-------- 6 files changed, 125 insertions(+), 78 deletions(-) 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({}) }) }) From c6c4cd7dcae4f82af311039a6f3acb456470d927 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 23:23:16 -0500 Subject: [PATCH 3/3] fix(opencode): validate deferred MCP calls --- packages/opencode/src/mcp/tool-search.ts | 14 +++++++- packages/opencode/src/session/tools.ts | 6 ++-- .../opencode/test/mcp/tool-search.test.ts | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/mcp/tool-search.ts b/packages/opencode/src/mcp/tool-search.ts index 465dfc3dc8..644c1a19d1 100644 --- a/packages/opencode/src/mcp/tool-search.ts +++ b/packages/opencode/src/mcp/tool-search.ts @@ -1,19 +1,24 @@ import { jsonSchema, tool, type JSONSchema7, type Tool, type ToolExecutionOptions } from "ai" import fuzzysort from "fuzzysort" import { Token } from "@opencode-ai/core/util/token" +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv" +import type { JsonSchemaType, JsonSchemaValidator } from "@modelcontextprotocol/sdk/validation" // 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 CONTROL_NAMES = ["mcp_search", "mcp_describe", "mcp_call"] const controls = new WeakSet() +const validator = new AjvJsonSchemaValidator() type Entry = { id: string description: string parameters: string schema: JSONSchema7 + validate: JsonSchemaValidator> tool: Tool } @@ -29,6 +34,10 @@ export function isControl(item: Tool) { return controls.has(item) } +export function collides(tools: Record) { + return CONTROL_NAMES.some((name) => tools[name]) +} + export function create(input: { tools: Record schemas: Record @@ -46,6 +55,7 @@ export function create(input: { description: item.description ?? "", parameters: Object.keys(schema.properties ?? {}).join(" "), schema, + validate: validator.getValidator>(schema as JsonSchemaType), tool: item, }, ] as const @@ -139,7 +149,9 @@ export function create(input: { async execute(args: { id: string; args?: Record }, options: ToolExecutionOptions) { const entry = resolve(entries, args.id) if (!entry.tool.execute) throw new Error(`MCP tool "${entry.id}" is not executable`) - return entry.tool.execute(args.args ?? {}, options) + const result = entry.validate(args.args ?? {}) + if (!result.valid) throw new Error(`Invalid arguments for MCP tool "${entry.id}": ${result.errorMessage}`) + return entry.tool.execute(result.data, options) }, }), } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 9be48725ac..b6916c7ded 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -502,6 +502,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { Object.assign(tools, mcpTools) return tools } + if (McpToolSearch.collides(tools)) { + Object.assign(tools, mcpTools) + return tools + } const schemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpSchemas[key]])) const controls = McpToolSearch.create({ @@ -509,8 +513,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { schemas, transformSchema: (schema) => ProviderTransform.schema(input.model, schema), }) - const collision = Object.keys(controls).find((key) => tools[key]) - if (collision) throw new Error(`Tool name reserved for MCP tool search: ${collision}`) Object.assign(tools, controls) return tools }) diff --git a/packages/opencode/test/mcp/tool-search.test.ts b/packages/opencode/test/mcp/tool-search.test.ts index 7d902840a9..7a14c84226 100644 --- a/packages/opencode/test/mcp/tool-search.test.ts +++ b/packages/opencode/test/mcp/tool-search.test.ts @@ -60,6 +60,11 @@ describe("MCP tool search", () => { expect(McpToolSearch.shouldUse(tools, schemas)).toBe(true) }) + test("detects control-name collisions", () => { + expect(McpToolSearch.collides({ mcp_call: target("mcp_call", "Plugin tool") })).toBe(true) + expect(McpToolSearch.collides({ other: target("other", "Plugin tool") })).toBe(false) + }) + test("exposes only the three stable control tools", async () => { expect(Object.keys(await catalog())).toEqual(["mcp_search", "mcp_describe", "mcp_call"]) }) @@ -120,6 +125,37 @@ describe("MCP tool search", () => { expect(result.output).toBe('{"title":"Cache bug"}') }) + test("validates hidden target arguments before execution", async () => { + let calls = 0 + const tools = McpToolSearch.create({ + tools: { + create_issue: tool({ + inputSchema: jsonSchema({}), + async execute() { + calls++ + return { output: "called" } + }, + }), + }, + schemas: { + create_issue: { + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + additionalProperties: false, + }, + }, + transformSchema: (schema) => schema, + }) + expect( + tools.mcp_call!.execute?.( + { id: "create_issue", args: {} }, + { toolCallId: "call", messages: [], abortSignal: new AbortController().signal }, + ), + ).rejects.toThrow('Invalid arguments for MCP tool "create_issue"') + expect(calls).toBe(0) + }) + test("suggests but does not execute inexact tool names", async () => { const tools = await McpToolSearch.create({ tools: {