refactor(opencode): consume native MCP tools in code mode

This commit is contained in:
Aiden Cline 2026-07-03 03:21:42 -05:00
commit 02696a7a5e
2 changed files with 100 additions and 132 deletions

View file

@ -1,6 +1,5 @@
import * as Tool from "./tool"
import type { Tool as AITool, ToolExecutionOptions } from "ai"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
@ -48,57 +47,34 @@ type CatalogEntry = {
key: string
server: string
local: string
description: string
tool: AITool
inputSchema: JsonSchema
outputSchema?: JsonSchema
tool: MCP.McpTool
}
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
function groupByServer(
mcpTools: Record<string, AITool>,
servers: readonly string[],
mcpDefs: Record<string, MCPToolDef> = {},
): Map<string, CatalogEntry[]> {
function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server =
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const tool = mcpTools[key]!
const def = mcpDefs[key]
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
server,
local,
description: tool.description ?? def?.description ?? "",
tool,
inputSchema: def?.inputSchema
? toJsonSchema(def.inputSchema)
: schema && typeof schema === "object"
? toJsonSchema(schema)
: { type: "object", properties: {} },
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
tool: mcpTools[key]!,
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
return groups
}
export function describeCatalog(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
): string {
export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string {
return CodeMode.make({
tools: toolTree(
[...groupByServer(mcpTools, servers, mcpDefs).values()]
.flat()
.filter((entry) => entry.tool.execute !== undefined),
[...groupByServer(mcpTools, servers).values()].flat(),
() => () => Effect.fail(toolError("Tool preview is not executable.")),
),
}).instructions()
@ -112,10 +88,7 @@ const lastSegment = (uri: string) => {
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
function projectMcpResult(raw: unknown, collect: (attachment: Attachment) => void): unknown {
if (raw === null || typeof raw !== "object") return raw
const record = raw as { structuredContent?: unknown; content?: unknown }
const content = Array.isArray(record.content) ? record.content : []
function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown {
const text: string[] = []
let files = 0
let images = 0
@ -124,53 +97,42 @@ function projectMcpResult(raw: unknown, collect: (attachment: Attachment) => voi
if (attachment.mime.startsWith("image/")) images += 1
collect(attachment)
}
for (const item of content) {
if (!item || typeof item !== "object") continue
const block = item as Record<string, unknown>
for (const block of result.content) {
switch (block.type) {
case "text":
if (typeof block.text === "string") text.push(block.text)
text.push(block.text)
break
case "image":
case "audio":
if (typeof block.data === "string" && typeof block.mimeType === "string") {
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
}
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
break
case "resource": {
const res = block.resource as Record<string, unknown> | undefined
if (res && typeof res === "object") {
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
const uri = typeof res.uri === "string" ? res.uri : undefined
if (typeof res.blob === "string") {
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
} else if (typeof res.text === "string") {
text.push(res.text)
}
if ("text" in block.resource) {
text.push(block.resource.text)
break
}
const mime = block.resource.mimeType ?? "application/octet-stream"
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
break
}
case "resource_link":
if (typeof block.uri === "string") {
push({
type: "file",
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
url: block.uri,
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
})
}
push({
type: "file",
mime: block.mimeType ?? "application/octet-stream",
url: block.uri,
filename: block.name,
})
break
}
}
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent
if (text.length > 0) return text.join("\n")
if (files > 0) {
const noun = files === images ? "image" : "file"
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
}
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
return raw
return null
}
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
@ -180,32 +142,51 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) =
for (const entry of catalog) {
const namespace = (tree[entry.server] ??= {})
namespace[entry.local] = SandboxTool.make({
description: entry.description,
input: entry.inputSchema,
output: entry.outputSchema,
description: entry.tool.def.description ?? "",
input: toJsonSchema(entry.tool.def.inputSchema),
output: entry.tool.def.outputSchema ? toJsonSchema(entry.tool.def.outputSchema) : undefined,
run: run(entry),
})
}
return tree
}
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* <R>(input: {
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
plugin: Plugin.Interface
entry: CatalogEntry
args: any
args: Record<string, unknown>
callID: string
options: ToolExecutionOptions
ctx: Tool.Context
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
}) {
yield* input.plugin.trigger(
"tool.execute.before",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
{ args: input.args },
)
const result: R = yield* Effect.gen(function* () {
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options)))
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
@ -248,9 +229,7 @@ export const CodeModeTool = Tool.define(
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
const catalog = [...groupByServer(mcpTools, servers, yield* mcp.defs()).values()]
.flat()
.filter((entry) => entry.tool.execute !== undefined)
const catalog = [...groupByServer(mcpTools, servers).values()].flat()
const calls: CallEntry[] = []
const attachments: Attachment[] = []
@ -262,16 +241,14 @@ export const CodeModeTool = Tool.define(
const callTool = (entry: CatalogEntry) => (input: unknown) =>
Effect.gen(function* () {
childCalls += 1
const raw = yield* invokeChildTool({
const result = yield* invokeChildTool({
plugin,
entry,
args: input ?? {},
args: (input ?? {}) as Record<string, unknown>,
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] },
ctx,
execute: entry.tool.execute!,
})
return projectMcpResult(raw, collect)
return projectMcpResult(result, collect)
}).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt

View file

@ -14,9 +14,7 @@ import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { McpCatalog } from "@/mcp/catalog"
import { MessageID, SessionID } from "@/session/schema"
import type { Tool as AITool } from "ai"
import { Effect, Layer, Schema } from "effect"
const ctx: Tool.Context = {
@ -34,16 +32,18 @@ function mcpTool(
name: string,
handler: (args: Record<string, unknown>) => unknown,
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
): AITool {
const client = {
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
outputSchema?: Record<string, unknown>,
): MCP.McpTool {
return {
def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
client: {
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
} as unknown as MCP.McpTool["client"],
}
return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any)
}
function harness(input: {
mcpTools: Record<string, AITool>
defs?: Record<string, MCPToolDef>
mcpTools: Record<string, MCP.McpTool>
servers: string[]
permission?: PermissionV1.Rule[]
trigger?: Plugin.Interface["trigger"]
@ -63,19 +63,17 @@ function harness(input: {
}),
Layer.mock(MCP.Service, {
tools: () => Effect.succeed(input.mcpTools),
defs: () => Effect.succeed(input.defs ?? {}),
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
}),
)
}
function serverNames(mcpTools: Record<string, AITool>, servers?: string[]) {
function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) {
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
}
function build(
mcpTools: Record<string, AITool>,
defs: Record<string, MCPToolDef> = {},
mcpTools: Record<string, MCP.McpTool>,
servers?: string[],
permission?: PermissionV1.Rule[],
trigger?: Plugin.Interface["trigger"],
@ -84,18 +82,13 @@ function build(
return Effect.runPromise(
CodeModeTool.pipe(
Effect.flatMap(Tool.init),
Effect.provide(harness({ mcpTools, defs, servers: names, permission, trigger })),
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
),
)
}
function describeFor(
mcpTools: Record<string, AITool>,
defs: Record<string, MCPToolDef> = {},
servers?: string[],
permission: PermissionV1.Rule[] = [],
) {
return describeCatalog(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers))
function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) {
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
}
describe("code mode execute", () => {
@ -106,26 +99,29 @@ describe("code mode execute", () => {
})
test("groups multi-underscore server names by longest matching prefix", () => {
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, {}, ["my_server"])
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
expect(description).toContain("- my_server (1 tool)")
expect(description).toContain("tools.my_server.do_thing(")
})
test("groupByServer uses the whole key as the server name when it has no underscore", () => {
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, {}, [])
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
expect(description).toContain("- standalone (1 tool)")
expect(description).toContain("tools.standalone.standalone(")
})
test("describeCatalog carries the raw MCP schemas for rendering", () => {
const defs: Record<string, MCPToolDef> = {
weather_current: {
name: "current",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
} as any,
}
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs, ["weather"])
const description = describeFor(
{
weather_current: mcpTool(
"current",
() => "",
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
),
},
["weather"],
)
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
})
@ -167,43 +163,42 @@ describe("code mode execute", () => {
})
test("signatures render the declared outputSchema as the return type", () => {
const defs: Record<string, MCPToolDef> = {
weather_current: {
name: "current",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
outputSchema: {
const description = describeFor({
weather_current: mcpTool(
"current",
() => "",
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{
type: "object",
properties: { tempC: { type: "number" }, summary: { type: "string" } },
required: ["tempC"],
},
} as any,
}
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs)
),
})
expect(description).toContain(
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
)
})
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
const tools: Record<string, AITool> = {}
const tools: Record<string, MCP.McpTool> = {}
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
for (let i = 0; i < 150; i++) {
const client = { callTool: async () => ({ content: [] }) }
tools[`alpha_op_${i}`] = McpCatalog.convertTool(
{
tools[`alpha_op_${i}`] = {
def: {
name: `op_${i}`,
description: `${filler}${i}`,
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
} as any,
client as any,
)
} as MCPToolDef,
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
}
}
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
type: "object",
properties: { topic: { type: "string", description: "Subject to look up" } },
required: ["topic"],
})
const description = describeFor(tools, {}, ["alpha", "zeta"])
const description = describeFor(tools, ["alpha", "zeta"])
expect(description).toContain("Available tools (PARTIAL - ")
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
@ -216,7 +211,7 @@ describe("code mode execute", () => {
expect(description).toContain("tools.alpha.op_0(")
expect(description).not.toContain("tools.alpha.op_99(")
const tool = await build(tools, {}, ["alpha", "zeta"])
const tool = await build(tools, ["alpha", "zeta"])
const out = await Effect.runPromise(
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
)
@ -385,7 +380,6 @@ describe("code mode execute", () => {
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
},
{},
undefined,
undefined,
trigger,
@ -421,7 +415,6 @@ describe("code mode execute", () => {
}
const tool = await build(
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
{},
undefined,
undefined,
trigger,
@ -520,7 +513,7 @@ describe("code mode execute", () => {
media_mixed: mcpTool("mixed", () => ({
content: [
{ type: "image", data: "PNG3", mimeType: "image/png" },
{ type: "resource_link", uri: "file:///tmp/report.pdf", mimeType: "application/pdf" },
{ type: "resource_link", uri: "file:///tmp/report.pdf", name: "report.pdf", mimeType: "application/pdf" },
],
})),
})
@ -633,7 +626,7 @@ describe("code mode permission visibility", () => {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
const description = describeFor(mcpTools, {}, ["github"], [deny("github_create_issue")])
const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")])
expect(description).toContain("tools.github.list_issues(")
expect(description).not.toContain("create_issue")
expect(description).toContain("- github (1 tool)")
@ -644,7 +637,7 @@ describe("code mode permission visibility", () => {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
const description = describeFor(mcpTools, {}, ["github"], [askRule("github_create_issue")])
const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")])
expect(description).toContain("tools.github.create_issue(")
expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("- github (2 tools)")
@ -660,7 +653,6 @@ describe("code mode permission visibility", () => {
}),
github_list_issues: mcpTool("list_issues", ok),
},
{},
["github"],
[deny("github_create_issue")],
)
@ -685,7 +677,6 @@ describe("code mode permission visibility", () => {
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
const tool = await build(
{ github_list_issues: mcpTool("list_issues", ok) },
{},
["github"],
[askRule("github_list_issues")],
)