diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e..46e38fca27 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -48,7 +48,6 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), - // 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 new file mode 100644 index 0000000000..644c1a19d1 --- /dev/null +++ b/packages/opencode/src/mcp/tool-search.ts @@ -0,0 +1,177 @@ +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 +} + +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 collides(tools: Record) { + return CONTROL_NAMES.some((name) => tools[name]) +} + +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, + validate: validator.getValidator>(schema as JsonSchemaType), + tool: item, + }, + ] as const + }), + ) + + if (entries.size === 0) 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.", + 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`) + 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) + }, + }), + } + for (const item of Object.values(result)) controls.add(item) + return result +} + +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..9d26a722f9 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 { McpToolSearch } from "@/mcp/tool-search" const USER_AGENT = `opencode/${InstallationVersion}` @@ -195,12 +197,15 @@ 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) + return Record.filter(input.tools, (item, key) => { + if (input.user.tools?.[key] === false) return false + if (!disabled.has(key)) return true + if (!McpToolSearch.isControl(item)) 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..b6916c7ded 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 { McpToolSearch } from "@/mcp/tool-search" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -381,12 +382,17 @@ 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 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( @@ -479,9 +485,35 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { return output }), ) - tools[key] = item + mcpTools[key] = item } + 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 providerSchemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpProviderSchemas[key]])) + if (!McpToolSearch.shouldUse(searchable, providerSchemas)) { + 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({ + tools: searchable, + schemas, + transformSchema: (schema) => ProviderTransform.schema(input.model, schema), + }) + 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..7a14c84226 --- /dev/null +++ b/packages/opencode/test/mcp/tool-search.test.ts @@ -0,0 +1,174 @@ +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("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("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"]) + }) + + 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("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: { + 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..a8b670f86f 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 { McpToolSearch } from "@/mcp/tool-search" type ConfigModel = NonNullable[string]["models"]>[string] @@ -173,6 +175,93 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.resolveTools", () => { + test("keeps MCP search controls under a generic deny rule", () => { + 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: controls, + agent, + user, + }), + ), + ).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({}) + }) +}) + describe("session.llm.ai-sdk adapter", () => { type AISDKAdapterEvent = Parameters[1]