diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index c72fb47b20..98e8205ed9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -159,6 +159,12 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> + /** + * Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`). + * Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code + * mode uses to render tool signatures (including return types) to the model. + */ + readonly defs: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> readonly resourceTemplates: ( @@ -680,6 +686,18 @@ export const layer = Layer.effect( return result }) + const defs = Effect.fn("MCP.defs")(function* () { + const result: Record = {} + const s = yield* InstanceState.get(state) + for (const [clientName, listed] of Object.entries(s.defs)) { + if (s.status[clientName]?.status !== "connected") continue + for (const mcpTool of listed) { + result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool + } + } + return result + }) + function collectFromConnected( s: State, listFn: (c: Client, timeout?: number) => Promise, @@ -982,6 +1000,7 @@ export const layer = Layer.effect( clients, instructions, tools, + defs, prompts, resources, resourceTemplates, diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 7389b324a6..3623ecb9cd 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -1,6 +1,7 @@ import { Tool } from "@/tool/tool" import { EffectBridge } from "@/effect/bridge" import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Effect, Schema } from "effect" export const CODE_MODE_TOOL = "execute" @@ -16,6 +17,16 @@ type Metadata = { error?: boolean } +/** + * A model-facing attachment: the same shape used for both child tool results and + * the program's final `return`, and identical to a session `FilePart` (minus the + * ids), so it lowers 1:1 into `Tool.ExecuteResult.attachments`. + */ +export type Attachment = NonNullable[number] + +/** The envelope every tool call resolves to, and the shape a program should `return`. */ +export type Envelope = { result: unknown; attachments?: Attachment[] } + // `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 { @@ -26,7 +37,15 @@ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const SEARCH = "search" const DESCRIBE = "describe" -type CatalogEntry = { path: string; key: string; server: string; local: string; description: string; tool: AITool } +type CatalogEntry = { + path: string + key: string + server: string + local: string + description: string + tool: AITool + outputSchema?: JSONSchema7 +} const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim() const brief = (text: string | undefined, max = 120) => { @@ -41,14 +60,20 @@ const toKey = (segments: readonly string[]) => segments.join("_").replaceAll("." /** * 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`. + * named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP + * definitions (keyed identically) so each entry retains its `outputSchema`. */ -export function groupByServer(mcpTools: Record, servers: readonly string[]): Map { +export function groupByServer( + mcpTools: Record, + servers: readonly string[], + mcpDefs: Record = {}, +): Map { const byLongest = [...servers].sort((a, b) => b.length - a.length) 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 output = mcpDefs[key]?.outputSchema as JSONSchema7 | undefined const entry: CatalogEntry = { path: `${server}.${local}`, key, @@ -56,6 +81,7 @@ export function groupByServer(mcpTools: Record, servers: readonl local, description: mcpTools[key]!.description ?? "", tool: mcpTools[key]!, + outputSchema: output, } groups.set(server, [...(groups.get(server) ?? []), entry]) } @@ -64,39 +90,61 @@ export function groupByServer(mcpTools: Record, servers: readonl const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) -function jsonType(def: JSONSchema7 | boolean | undefined): string { +/** + * Render a JSON Schema as a compact TypeScript-ish type string for model-facing + * signatures. Depth-limited and total — never throws, falls back to `any`/`object`. + */ +export function renderType(def: JSONSchema7 | boolean | undefined, depth = 0): string { if (!def || typeof def === "boolean") return "any" if (Array.isArray(def.enum)) return def.enum.map((value) => JSON.stringify(value)).join(" | ") + if (def.const !== undefined) return JSON.stringify(def.const) + if (Array.isArray(def.anyOf ?? def.oneOf)) { + const alts = (def.anyOf ?? def.oneOf)! + return alts.map((alt) => renderType(alt as JSONSchema7, depth)).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: + case "string": + case "number": + case "boolean": + case "null": return type + case "array": { + const items = Array.isArray(def.items) ? def.items[0] : def.items + return `${renderType(items as JSONSchema7 | undefined, depth + 1)}[]` + } } + if (type === "object" || def.properties) { + if (depth >= 3) return "object" + const props = def.properties ?? {} + const required = new Set(Array.isArray(def.required) ? def.required : []) + const fields = Object.entries(props).map( + ([name, value]) => `${name}${required.has(name) ? "" : "?"}: ${renderType(value as JSONSchema7, depth + 1)}`, + ) + return fields.length > 0 ? `{ ${fields.join("; ")} }` : "object" + } + return "any" } -function inputHint(tool: AITool): string { +function inputType(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("; ")} }` : "{}" + if (!schema?.properties || typeof schema.properties !== "object") return "input" + return renderType(schema) } catch { return "input" } } +/** The return type the model sees for any tool: the structured `outputSchema` (when + * the MCP server declares one) wrapped in the result envelope, else `unknown`. */ +const returnType = (outputSchema: JSONSchema7 | undefined) => + `Promise<{ result: ${outputSchema ? renderType(outputSchema) : "unknown"}; attachments?: Attachment[] }>` + const signatureFor = (entry: CatalogEntry) => - `tools${access(entry.server)}${access(entry.local)}(${inputHint(entry.tool)})` + `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` /** * The execute tool description: the calling convention, the discovery API, and a @@ -109,10 +157,16 @@ export function describe(groups: Map): string { "", "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.", + "- `await tools.describe(path)` -> `{ path, description, signature, inputSchema, outputSchema? }`", + "- Call a tool by its path: `await tools..(input)`. Each resolves to `{ result, attachments? }`.", "", - "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.", + "Every tool call and your final `return` use the same envelope: `{ result, attachments? }`.", + "`result` is the structured data; `attachments` carries images/files for the user. Return a whole tool", + "result to forward its attachments, or return only its `.result` to drop the media. You cannot read the", + "contents of an attachment in code — only pass it along.", + "", + "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.", ] if (groups.size === 0) { lines.push("", "No MCP servers are currently connected.") @@ -125,24 +179,74 @@ export function describe(groups: Map): string { return lines.join("\n") } +const lastSegment = (uri: string) => { + const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "") + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1) + return segment.length > 0 ? segment : undefined +} + +const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` + /** - * 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. + * Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result` + * is the structured content (or joined text); media blocks (image/audio/resource) + * become attachments. Lenient — never throws on unexpected shapes. */ -export function toolResultValue(result: unknown): unknown { - if (result === null || typeof result !== "object") return result +export function toEnvelope(result: unknown): Envelope { + if (result === null || typeof result !== "object") return { result } const record = result as { structuredContent?: unknown; content?: unknown } - if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent - if (Array.isArray(record.content)) { - const text = record.content - .filter((item): item is { type: "text"; text: string } => item?.type === "text" && typeof item.text === "string") - .map((item) => item.text) - .join("\n") - if (text.length > 0) return text - return record.content + const attachments: Attachment[] = [] + const text: string[] = [] + const content = Array.isArray(record.content) ? record.content : [] + for (const item of content) { + if (!item || typeof item !== "object") continue + const block = item as Record + switch (block.type) { + case "text": + if (typeof block.text === "string") text.push(block.text) + break + case "image": + case "audio": + if (typeof block.data === "string" && typeof block.mimeType === "string") { + attachments.push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) + } + break + case "resource": { + const res = block.resource as Record | 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") { + attachments.push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) + } else if (typeof res.text === "string") { + text.push(res.text) + } + } + break + } + case "resource_link": + if (typeof block.uri === "string") { + attachments.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), + }) + } + break + } } - return result + + const value = + record.structuredContent !== undefined && record.structuredContent !== null + ? record.structuredContent + : text.length > 0 + ? text.join("\n") + : content.length > 0 + ? undefined // media-only result + : result + + return attachments.length > 0 ? { result: value, attachments } : { result: value } } /** Coerce the program's return value to model-facing text without ever failing on shape. */ @@ -156,6 +260,28 @@ export function formatValue(value: unknown): string { } } +const isAttachment = (value: unknown): value is Attachment => { + if (!value || typeof value !== "object") return false + const a = value as Record + return a.type === "file" && typeof a.mime === "string" && typeof a.url === "string" +} + +/** + * Lower the program's return value into model-facing output + attachments. The + * value is treated as a `{ result, attachments? }` envelope when it has a `result` + * key; otherwise the whole value is the result. Attachments are model-curated. + */ +export function fromReturn(value: unknown): { output: string; attachments?: Attachment[] } { + if (value !== null && typeof value === "object" && "result" in value) { + const env = value as { result: unknown; attachments?: unknown } + const attachments = Array.isArray(env.attachments) ? env.attachments.filter(isAttachment) : [] + return attachments.length > 0 + ? { output: formatValue(env.result), attachments } + : { output: formatValue(env.result) } + } + return { output: formatValue(value) } +} + function errorMessage(error: unknown): string { if (error instanceof Error) return error.message if (typeof error === "string") return error @@ -166,8 +292,12 @@ function errorMessage(error: unknown): string { } } -export function define(mcpTools: Record, servers: readonly string[]) { - const groups = groupByServer(mcpTools, servers) +export function define( + mcpTools: Record, + mcpDefs: Record, + servers: readonly string[], +) { + const groups = groupByServer(mcpTools, servers, mcpDefs) const catalog: CatalogEntry[] = [...groups.values()].flat() const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) @@ -202,7 +332,13 @@ export function define(mcpTools: Record, servers: readonly strin } catch { inputSchema = undefined } - return { path: entry.path, description: entry.description, signature: signatureFor(entry), inputSchema } + return { + path: entry.path, + description: entry.description, + signature: signatureFor(entry), + inputSchema, + ...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}), + } } return Tool.define( @@ -228,7 +364,7 @@ export function define(mcpTools: Record, servers: readonly strin }), ), ) - return toolResultValue(result) + return toEnvelope(result) }) // Recursive path-accumulating proxy: `tools..(args)` and @@ -261,14 +397,15 @@ export function define(mcpTools: Record, servers: readonly strin try: () => new AsyncFunction("tools", params.code)(tools), catch: (error) => error, }).pipe( - Effect.map( - (value) => - ({ - title: "Code mode", - metadata: { toolCalls: calls }, - output: formatValue(value), - }) satisfies Tool.ExecuteResult, - ), + Effect.map((value) => { + const { output, attachments } = fromReturn(value) + return { + title: "Code mode", + metadata: { toolCalls: calls }, + output, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + } satisfies Tool.ExecuteResult + }), Effect.catch((error) => Effect.succeed({ title: "Code mode", diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 7f65c64301..9e10a9ce97 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -99,7 +99,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const codeModeTool = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 ? yield* Tool.init( - yield* CodeModeTool.define(mcpTools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)), + yield* CodeModeTool.define( + mcpTools, + yield* mcp.defs(), + Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), + ), ) : undefined const registryTools = yield* registry.tools({ diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 2433e47a3a..8f9811b75d 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Parameters, define, describe as describeTools, formatValue, groupByServer, toolResultValue } from "@/session/code-mode" +import { Parameters, define, describe as describeTools, formatValue, groupByServer, toEnvelope } from "@/session/code-mode" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Agent } from "@/agent/agent" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" @@ -43,9 +44,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[]) { +function build(mcpTools: Record, defs: Record = {}, servers?: string[]) { const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] - return Effect.runPromise(define(mcpTools, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) + return Effect.runPromise(define(mcpTools, defs, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) } describe("code mode execute", () => { @@ -97,7 +98,9 @@ describe("code mode execute", () => { ) 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 })") + expect(desc.signature).toBe( + "tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>", + ) 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") @@ -126,7 +129,7 @@ describe("code mode execute", () => { }) const output = await Effect.runPromise( - tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx), + tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.result.toUpperCase()" }, ctx), ) expect(seen).toEqual([{ name: "world" }]) @@ -147,8 +150,8 @@ describe("code mode execute", () => { { code: ` const first = await tools.math.add({ a: 1, b: 2 }) - const second = await tools.math.add({ a: first.sum, b: 10 }) - return { total: second.sum } + const second = await tools.math.add({ a: first.result.sum, b: 10 }) + return { total: second.result.sum } `, }, ctx, @@ -167,7 +170,7 @@ describe("code mode execute", () => { const output = await Effect.runPromise( tool.execute( - { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" }, + { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a.result + b.result" }, ctx, ), ) @@ -217,12 +220,71 @@ describe("code mode execute", () => { expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) }) - test("unit: toolResultValue and formatValue", () => { - expect(toolResultValue({ structuredContent: { x: 1 }, content: [] })).toEqual({ x: 1 }) - expect(toolResultValue({ content: [{ type: "text", text: "hi" }] })).toBe("hi") - expect(toolResultValue("raw")).toBe("raw") + test("unit: toEnvelope wraps result and extracts media as attachments", () => { + expect(toEnvelope({ structuredContent: { x: 1 }, content: [] })).toEqual({ result: { x: 1 } }) + expect(toEnvelope({ content: [{ type: "text", text: "hi" }] })).toEqual({ result: "hi" }) + expect(toEnvelope("raw")).toEqual({ result: "raw" }) + + // image/audio blocks become data-URL file attachments; text stays in result + expect( + toEnvelope({ + content: [ + { type: "text", text: "see image" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ], + }), + ).toEqual({ + result: "see image", + attachments: [{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }], + }) + + // media-only result has an undefined result but still surfaces the attachment + expect(toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] })).toEqual({ + result: undefined, + attachments: [{ type: "file", mime: "image/jpeg", url: "data:image/jpeg;base64,BBBB" }], + }) + }) + + test("unit: formatValue", () => { expect(formatValue("text")).toBe("text") expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)) expect(formatValue(undefined)).toBe("undefined") }) + + test("describe shows the structured return type when the tool declares an outputSchema", async () => { + const tools = { weather_current: mcpTool("current", () => "", { type: "object", properties: { city: { type: "string" } } }) } + const defs: Record = { + weather_current: { + name: "current", + inputSchema: { type: "object", properties: { city: { type: "string" } } }, + outputSchema: { type: "object", properties: { tempC: { type: "number" }, summary: { type: "string" } }, required: ["tempC"] }, + } as any, + } + const tool = await build(tools, defs) + const described = await Effect.runPromise(tool.execute({ code: "return await tools.describe('weather.current')" }, ctx)) + const desc = JSON.parse(described.output) + expect(desc.signature).toBe( + "tools.weather.current(input: { city?: string }): Promise<{ result: { tempC: number; summary?: string }; attachments?: Attachment[] }>", + ) + expect(desc.outputSchema).toBeDefined() + }) + + test("forwards attachments from a returned tool result and drops them when only .result is returned", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ + content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }], + structuredContent: { name: "shot.png" }, + })), + }) + + const forwarded = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) + expect(forwarded.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) + expect(JSON.parse(forwarded.output)).toEqual({ name: "shot.png" }) + + const suppressed = await Effect.runPromise( + tool.execute({ code: "const r = await tools.shot.take({}); return { result: r.result }" }, ctx), + ) + expect(suppressed.attachments).toBeUndefined() + expect(JSON.parse(suppressed.output)).toEqual({ name: "shot.png" }) + }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf..01b1af5b99 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { clients: () => Effect.succeed({}), instructions: () => Effect.succeed(instructions), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840..3bbb4b1fc5 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -39,6 +39,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}),