fix(core): expose MCP output schemas to code mode (#35502)

This commit is contained in:
Aiden Cline 2026-07-05 23:12:45 -05:00 committed by GitHub
parent 50a762e7b9
commit 4e019ba5d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 106 additions and 2 deletions

View file

@ -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: () =>

View file

@ -42,6 +42,7 @@ export class Tool extends Schema.Class<Tool>("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({

View file

@ -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({

View file

@ -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())

View file

@ -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<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = 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