diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index f4894c1361..d211e8353a 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -7,7 +7,7 @@ export const CODE_MODE_TOOL = "execute" export const Parameters = Schema.Struct({ code: Schema.String.annotate({ - description: "JavaScript to run. Call tools as `await tools..(input)` and `return` the final value.", + description: "JavaScript to run. Discover tools with `tools.search`/`tools.describe`, call them, and `return` the final value.", }), }) @@ -16,6 +16,8 @@ type Metadata = { error?: boolean } +export type Namespace = { name: string; description?: string } + // `new Function`/`AsyncFunction` is not on the global scope, so reach it via the // prototype of an async function literal. The body may use top-level `await` and `return`. const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as { @@ -23,24 +25,41 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as } const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +const SEARCH = "search" +const DESCRIBE = "describe" -type NamespacedTool = { local: string; key: string; tool: AITool } +type CatalogEntry = { path: string; key: string; server: string; local: string; description: string; tool: AITool } + +const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim() +const brief = (text: string | undefined, max = 120) => { + const line = firstLine(text) + return line.length > max ? line.slice(0, max - 1) + "…" : line +} + +/** Re-join accessed segments into the flat catalog key (`server_tool`). The + * server/tool split is cosmetic, so both `tools.a.b` and `tools["a.b"]` resolve. */ +const toKey = (segments: readonly string[]) => segments.join("_").replaceAll(".", "_") /** - * Group the flat `server_tool` catalog into per-server namespaces for display. - * `servers` are the sanitized MCP client names; the longest matching prefix wins - * so a server named `a_b` is preferred over `a` for the key `a_b_tool`. Routing - * never depends on this split — it re-joins `${server}_${local}` back to the key. + * Group the flat `server_tool` catalog into per-server namespaces. `servers` are + * the sanitized MCP client names; the longest matching prefix wins so a server + * named `a_b` beats `a` for the key `a_b_tool`. */ -export function groupByServer(mcpTools: Record, servers: readonly string[]): Map { +export function groupByServer(mcpTools: Record, servers: readonly string[]): Map { const byLongest = [...servers].sort((a, b) => b.length - a.length) - const groups = new Map() + const groups = new Map() for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) { const server = byLongest.find((name) => key.startsWith(name + "_")) ?? key.slice(0, key.indexOf("_")) const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key - const entry = groups.get(server) ?? [] - entry.push({ local, key, tool: mcpTools[key]! }) - groups.set(server, entry) + const entry: CatalogEntry = { + path: `${server}.${local}`, + key, + server, + local, + description: mcpTools[key]!.description ?? "", + tool: mcpTools[key]!, + } + groups.set(server, [...(groups.get(server) ?? []), entry]) } return groups } @@ -78,26 +97,34 @@ function inputHint(tool: AITool): string { } } -const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim() +const signatureFor = (entry: CatalogEntry) => + `tools${access(entry.server)}${access(entry.local)}(${inputHint(entry.tool)})` -export function describe(groups: Map): string { +/** + * The execute tool description: the calling convention, the discovery API, and a + * list of namespaces only — never the full tool catalog. Per-tool signatures are + * fetched on demand with `tools.describe` so the prompt stays small. + */ +export function describe(groups: Map, descriptions?: Map): string { const lines = [ - "Execute JavaScript with access to connected MCP tools.", - "Every connected MCP server is a namespace on `tools`. Call a tool with `await tools..(input)`; each returns a Promise.", - "Compose multiple calls in one program and `return` the final value — intermediate results stay in the sandbox and never re-enter the conversation.", + "Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).", + "", + "Discover tools inside your program, then call them:", + "- `await tools.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`", + "- `await tools.describe(path)` -> `{ path, description, signature, inputSchema }`", + "- Call a tool by its path: `await tools..(input)` or `await tools[path](input)`. Each returns a Promise.", + "", + "Compose multiple calls in one program and `return` the final value — intermediate results stay in the sandbox and never re-enter the conversation. Use `tools.search('', { namespace })` to list a namespace's tools.", ] if (groups.size === 0) { lines.push("", "No MCP servers are currently connected.") return lines.join("\n") } lines.push("", "Available namespaces:") - for (const [server, tools] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { - lines.push("", `// ${server}`) - for (const { local, tool } of tools) { - const signature = `tools${access(server)}${access(local)}(${inputHint(tool)})` - const summary = firstLine(tool.description) - lines.push(summary ? `${signature} // ${summary}` : signature) - } + for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { + const note = brief(descriptions?.get(server)) + const count = `${entries.length} tool${entries.length === 1 ? "" : "s"}` + lines.push(`- ${server} (${count})${note ? ` — ${note}` : ""}`) } return lines.join("\n") } @@ -143,12 +170,53 @@ function errorMessage(error: unknown): string { } } -export function define(mcpTools: Record, servers: readonly string[]) { - const groups = groupByServer(mcpTools, servers) +export function define(mcpTools: Record, namespaces: ReadonlyArray) { + const groups = groupByServer( + mcpTools, + namespaces.map((n) => n.name), + ) + const descriptions = new Map(namespaces.map((n) => [n.name, n.description] as const)) + const catalog: CatalogEntry[] = [...groups.values()].flat() + const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) + + const search = (query: unknown, options: unknown) => { + const q = (typeof query === "string" ? query : "").toLowerCase() + const opts = (options ?? {}) as { namespace?: unknown; limit?: unknown } + const namespace = typeof opts.namespace === "string" ? opts.namespace : undefined + const limit = typeof opts.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : 25 + const matched = catalog + .filter((entry) => (namespace ? entry.server === namespace : true)) + .filter((entry) => (q ? `${entry.path} ${entry.description}`.toLowerCase().includes(q) : true)) + return { + items: matched.slice(0, limit).map((entry) => ({ path: entry.path, description: brief(entry.description) })), + total: matched.length, + } + } + + const describeTool = (path: unknown) => { + if (typeof path !== "string") return { error: { code: "invalid_path", message: "describe expects a tool path string." } } + const entry = byKey.get(toKey([path])) + if (!entry) { + const segment = path.split(/[._]/)[0] ?? "" + const suggestions = catalog + .filter((item) => item.server === segment || item.path.includes(path)) + .slice(0, 5) + .map((item) => item.path) + return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } } + } + let inputSchema: unknown + try { + inputSchema = asSchema(entry.tool.inputSchema).jsonSchema + } catch { + inputSchema = undefined + } + return { path: entry.path, description: entry.description, signature: signatureFor(entry), inputSchema } + } + return Tool.define( CODE_MODE_TOOL, Effect.succeed>({ - description: describe(groups), + description: describe(groups, descriptions), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { const run = yield* EffectBridge.make() @@ -171,32 +239,31 @@ export function define(mcpTools: Record, servers: readonly strin return toolResultValue(result) }) - // `tools..(args)` — the server/tool split is cosmetic; routing - // re-joins `${server}_${tool}` back into the original flat catalog key. - const namespace = (server: string) => - new Proxy(Object.create(null) as Record, { + // Recursive path-accumulating proxy: `tools..(args)` and + // `tools[path](args)` both resolve to a flat catalog key, while the reserved + // top-level `tools.search`/`tools.describe` provide on-demand discovery. + const make = (segments: readonly string[]): unknown => + new Proxy(function () {} as object, { get(_target, prop) { if (typeof prop !== "string" || prop === "then") return undefined - const key = `${server}_${prop}` + return make([...segments, prop]) + }, + apply(_target, _thisArg, args: unknown[]) { + if (segments.length === 1 && segments[0] === SEARCH) return search(args[0], args[1]) + if (segments.length === 1 && segments[0] === DESCRIBE) return describeTool(args[0]) + const key = toKey(segments) const tool = mcpTools[key] if (!tool || !tool.execute) { - return () => { - throw new Error(`Unknown tool 'tools.${server}.${prop}'. Available: ${Object.keys(mcpTools).join(", ")}`) - } - } - return (args: unknown) => { - calls.push(key) - return run.promise(invoke(key, tool, args)) + throw new Error( + `Unknown tool 'tools.${segments.join(".")}'. Use tools.search(query) to discover available tools.`, + ) } + calls.push(key) + return run.promise(invoke(key, tool, args[0])) }, }) - const tools = new Proxy(Object.create(null) as Record, { - get(_target, prop) { - if (typeof prop !== "string" || prop === "then") return undefined - return namespace(prop) - }, - }) + const tools = make([]) return yield* Effect.tryPromise({ try: () => new AsyncFunction("tools", params.code)(tools), diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 115c1bd314..59df662117 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -94,17 +94,18 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // When code mode is enabled and MCP tools are present, expose them through the // single code-mode `execute` tool instead of registering each MCP tool directly // (see the early return below). Code mode is experimental and off by default. - // Sanitized client names give code mode the per-server namespaces; tool keys are - // `sanitize(server)_sanitize(tool)`, so the names match the catalog key prefixes. - const codeModeTool = - flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 - ? yield* Tool.init( - yield* CodeModeTool.define( - mcpTools, - Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), - ), - ) - : undefined + let codeModeTool: Tool.Def | undefined + if (flags.experimentalCodeMode && Object.keys(mcpTools).length > 0) { + // Namespaces are sanitized client names (tool keys are `sanitize(server)_sanitize(tool)`, + // so they match the catalog key prefixes). The brief per-namespace note reuses the + // server's MCP instructions, whose full text is already in the system prompt. + const instructions = new Map((yield* mcp.instructions()).map((item) => [item.name, item.instructions] as const)) + const namespaces = Object.keys(yield* mcp.clients()).map((name) => ({ + name: McpCatalog.sanitize(name), + description: instructions.get(name), + })) + codeModeTool = yield* Tool.init(yield* CodeModeTool.define(mcpTools, namespaces)) + } const registryTools = yield* registry.tools({ modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 02b8524971..629aa1d6c2 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -42,9 +42,9 @@ const layer = Layer.mergeAll( ) // Derive sanitized server namespaces from the catalog keys, mirroring how -// session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`. -function build(mcpTools: Record, servers?: string[]) { - const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] +// session/tools.ts passes namespaces built from `mcp.clients()` + `mcp.instructions()`. +function build(mcpTools: Record, namespaces?: Array<{ name: string; description?: string }>) { + const names = namespaces ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))].map((name) => ({ name })) return Effect.runPromise(define(mcpTools, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) } @@ -55,26 +55,58 @@ describe("code mode execute", () => { await expect(Effect.runPromise(decode({}))).rejects.toThrow() }) - test("describes tools grouped into per-server namespaces with signatures", () => { + test("describes namespaces only (not per-tool signatures) and the discovery API", () => { + const groups = groupByServer( + { + github_create_issue: mcpTool("create_issue", () => ""), + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }, + ["github", "linear"], + ) const description = describeTools( - groupByServer( - { - github_create_issue: mcpTool("create_issue", () => "", { - type: "object", - properties: { title: { type: "string" }, body: { type: "string" } }, - required: ["title"], - }), - linear_search: mcpTool("search", () => ""), - }, - ["github", "linear"], - ), + groups, + new Map([ + ["github", "GitHub repository automation.\nmore detail here"], + ["linear", undefined], + ]), ) - expect(description).toContain("await tools..(input)") - expect(description).toContain("// github") - expect(description).toContain("tools.github.create_issue({ title: string; body?: string })") - expect(description).toContain("// linear") - expect(description).toContain("tools.linear.search") + expect(description).toContain("tools.search(query") + expect(description).toContain("tools.describe(path)") + expect(description).toContain("- github (2 tools) — GitHub repository automation.") + expect(description).toContain("- linear (1 tool)") + // The full catalog must NOT be inlined in the prompt. + expect(description).not.toContain("create_issue") + }) + + test("tools.search and tools.describe expose the catalog on demand", async () => { + const tool = await build({ + github_create_issue: mcpTool("create_issue", () => "", { + type: "object", + properties: { title: { type: "string" }, body: { type: "string" } }, + required: ["title"], + }), + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }) + + const searched = await Effect.runPromise( + tool.execute({ code: "return await tools.search('issue', { namespace: 'github' })" }, ctx), + ) + const search = JSON.parse(searched.output) + expect(search.total).toBe(2) + expect(search.items.map((i: any) => i.path).sort()).toEqual(["github.create_issue", "github.list_issues"]) + + const described = await Effect.runPromise( + tool.execute({ code: "return await tools.describe('github.create_issue')" }, ctx), + ) + const desc = JSON.parse(described.output) + expect(desc.path).toBe("github.create_issue") + expect(desc.signature).toBe("tools.github.create_issue({ title: string; body?: string })") + + const missing = await Effect.runPromise(tool.execute({ code: "return await tools.describe('github.nope')" }, ctx)) + expect(JSON.parse(missing.output).error.code).toBe("tool_not_found") }) test("groups multi-underscore server names by longest matching prefix", () => { @@ -157,12 +189,12 @@ describe("code mode execute", () => { expect(output.metadata.error).toBe(true) }) - test("reports an unknown tool with the available names", async () => { + test("reports an unknown tool and points to discovery", async () => { const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx)) expect(output.metadata.error).toBe(true) expect(output.output).toContain("Unknown tool 'tools.known.missing'") - expect(output.output).toContain("known_tool") + expect(output.output).toContain("tools.search") }) test("propagates an MCP tool error into the program", async () => {