diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 84ccbd70bbed..02362f6428e2 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -61,6 +61,7 @@ export interface ToolDefinition { readonly name: string readonly description: string | undefined readonly inputSchema: unknown + readonly outputSchema: unknown } export interface PromptDefinition { @@ -235,6 +236,7 @@ export const connect = Effect.fnUntraced(function* ( name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + outputSchema: "outputSchema" in tool ? tool.outputSchema : undefined, })) }), prompts: () => diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index ffc7a247fd1c..1ce8c3d3ecaa 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -42,6 +42,7 @@ export class Tool extends Schema.Class("MCP.Tool")({ name: Schema.String, description: Schema.String.pipe(Schema.optional), inputSchema: Schema.Unknown.pipe(Schema.optional), + outputSchema: Schema.Unknown.pipe(Schema.optional), }) {} export const ToolResultContent = Schema.Union([ @@ -391,7 +392,13 @@ export const layer = Layer.effect( } satisfies MCPClient.ElicitationHandler const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => - new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) + new Tool({ + server, + name: def.name, + description: def.description, + inputSchema: def.inputSchema, + outputSchema: def.outputSchema, + }) const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) => new Prompt({ diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 8cb0117ebbf1..7503e4e94e53 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -45,6 +45,7 @@ export const layer = Layer.effectDiscard( properties: schema.properties ?? {}, additionalProperties: false, }, + outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined, execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ diff --git a/packages/core/test/fixture/mcp-output-schema.ts b/packages/core/test/fixture/mcp-output-schema.ts new file mode 100644 index 000000000000..cb853be00280 --- /dev/null +++ b/packages/core/test/fixture/mcp-output-schema.ts @@ -0,0 +1,40 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" + +const server = new Server({ name: "output-schema", version: "1.0.0" }, { capabilities: { tools: {} } }) + +server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), +) + +await server.connect(new StdioServerTransport()) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2e5822f4ffe6..e40e8dfb2c99 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1,8 +1,10 @@ +import path from "node:path" import { describe, expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { ConfigMCP } from "@opencode-ai/core/config/mcp" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -15,7 +17,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" -import { settleTool, toolIdentity, waitForTool } from "./lib/tool" +import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void @@ -29,6 +31,11 @@ const mcp = Layer.mock(MCP.Service, { name: "search", description: "Search", inputSchema: { type: "object", properties: {} }, + outputSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + }, }), ]), callTool: (input) => @@ -133,6 +140,53 @@ test("preserves output schema validation across paginated tool discovery", async } }) +test("retains output schemas across paginated MCP discovery", async () => { + const tools = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "pagination", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")], + }), + import.meta.dir, + ) + return yield* connection.tools() + }), + ), + ) + + expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([ + { + name: "first", + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + { + name: "second", + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ]) +}) + +it.effect("advertises MCP output schemas to Code Mode", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "execute") + const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") + + expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{ ok: boolean }>") + }), +) + it.effect("waits for permission before calling an MCP tool", () => Effect.gen(function* () { calls = 0