feat(opencode): namespace code mode tools by MCP server

Expose connected MCP tools to code mode as per-server namespaces
(`tools.<server>.<tool>(args)`) and generate the execute tool description
from the catalog: one namespace block per server, each tool rendered with a
TypeScript-style input signature derived from its JSON schema plus its
description, and the calling convention stated up front.

The server/tool split is cosmetic; child-call routing re-joins the segments
into the existing flat catalog key, so it stays exact regardless of
underscores in server or tool names.
This commit is contained in:
Aiden Cline
2026-06-29 18:33:08 -05:00
parent 49f20b6a30
commit b0aa6bfb61
3 changed files with 165 additions and 49 deletions
+109 -28
View File
@@ -1,13 +1,13 @@
import { Tool } from "@/tool/tool"
import { EffectBridge } from "@/effect/bridge"
import type { Tool as AITool } from "ai"
import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai"
import { Effect, Schema } from "effect"
export const CODE_MODE_TOOL = "execute"
export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: "JavaScript to run. Call tools as `await tools.<name>(args)` and `return` the final value.",
description: "JavaScript to run. Call tools as `await tools.<server>.<tool>(input)` and `return` the final value.",
}),
})
@@ -22,19 +22,90 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as
new (...args: string[]): (...args: unknown[]) => Promise<unknown>
}
function describe(mcpTools: Record<string, AITool>) {
const names = Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))
return [
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
type NamespacedTool = { local: string; key: string; tool: AITool }
/**
* 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.
*/
export function groupByServer(mcpTools: Record<string, AITool>, servers: readonly string[]): Map<string, NamespacedTool[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, NamespacedTool[]>()
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)
}
return groups
}
const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
function jsonType(def: JSONSchema7 | boolean | undefined): string {
if (!def || typeof def === "boolean") return "any"
if (Array.isArray(def.enum)) return def.enum.map((value) => JSON.stringify(value)).join(" | ")
const type = Array.isArray(def.type) ? def.type[0] : def.type
switch (type) {
case "integer":
return "number"
case "array":
return "any[]"
case undefined:
return "any"
default:
return type
}
}
function inputHint(tool: AITool): string {
try {
const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined
const props = schema?.properties
if (!props || typeof props !== "object") return "input"
const required = new Set(Array.isArray(schema?.required) ? schema.required : [])
const fields = Object.entries(props).map(
([name, def]) => `${name}${required.has(name) ? "" : "?"}: ${jsonType(def as JSONSchema7)}`,
)
return fields.length > 0 ? `{ ${fields.join("; ")} }` : "{}"
} catch {
return "input"
}
}
const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim()
export function describe(groups: Map<string, NamespacedTool[]>): string {
const lines = [
"Execute JavaScript with access to connected MCP tools.",
"Each tool is callable as `await tools.<name>(args)`; `return` the final value.",
names.length > 0 ? `Available tools: ${names.join(", ")}` : "No MCP tools are currently connected.",
].join("\n")
"Every connected MCP server is a namespace on `tools`. Call a tool with `await tools.<server>.<tool>(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.",
]
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)
}
}
return lines.join("\n")
}
/**
* Reduce an MCP tool result to the value the program should see: structured
* content when present, otherwise the joined text blocks, otherwise the raw
* result. Mirrors how the model-facing output is derived elsewhere.
* result.
*/
export function toolResultValue(result: unknown): unknown {
if (result === null || typeof result !== "object") return result
@@ -72,25 +143,26 @@ function errorMessage(error: unknown): string {
}
}
export function define(mcpTools: Record<string, AITool>) {
export function define(mcpTools: Record<string, AITool>, servers: readonly string[]) {
const groups = groupByServer(mcpTools, servers)
return Tool.define(
CODE_MODE_TOOL,
Effect.succeed<Tool.DefWithoutID<typeof Parameters, Metadata>>({
description: describe(mcpTools),
description: describe(groups),
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
const run = yield* EffectBridge.make()
const calls: string[] = []
// Each `tools.<name>(args)` call runs the native MCP tool through the
// permission gate, so approving `execute` does not approve every child call.
const invoke = (name: string, tool: AITool, args: unknown) =>
// Each tool call runs the native MCP tool through the permission gate, so
// approving `execute` does not approve every child call.
const invoke = (key: string, tool: AITool, args: unknown) =>
Effect.gen(function* () {
yield* ctx.ask({ permission: name, metadata: {}, patterns: ["*"], always: ["*"] })
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
const result = yield* Effect.promise(() =>
Promise.resolve(
tool.execute!(args ?? {}, {
toolCallId: ctx.callID ?? name,
toolCallId: ctx.callID ?? key,
abortSignal: ctx.abort,
messages: [],
}),
@@ -99,21 +171,30 @@ export function define(mcpTools: Record<string, AITool>) {
return toolResultValue(result)
})
// `tools.<server>.<tool>(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<string, unknown>, {
get(_target, prop) {
if (typeof prop !== "string" || prop === "then") return undefined
const key = `${server}_${prop}`
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))
}
},
})
const tools = new Proxy(Object.create(null) as Record<string, unknown>, {
get(_target, prop) {
if (typeof prop !== "string" || prop === "then") return undefined
const tool = mcpTools[prop]
if (!tool || !tool.execute) {
return () => {
throw new Error(
`Unknown tool '${prop}'. Available tools: ${Object.keys(mcpTools).join(", ") || "(none)"}`,
)
}
}
return (args: unknown) => {
calls.push(prop)
return run.promise(invoke(prop, tool, args))
}
return namespace(prop)
},
})