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:
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user