fix(core): expose MCP output schemas to code mode (#35502)
This commit is contained in:
parent
50a762e7b9
commit
4e019ba5d9
5 changed files with 106 additions and 2 deletions
|
|
@ -61,6 +61,7 @@ export interface ToolDefinition {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly description: string | undefined
|
readonly description: string | undefined
|
||||||
readonly inputSchema: unknown
|
readonly inputSchema: unknown
|
||||||
|
readonly outputSchema: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptDefinition {
|
export interface PromptDefinition {
|
||||||
|
|
@ -235,6 +236,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
|
outputSchema: "outputSchema" in tool ? tool.outputSchema : undefined,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
prompts: () =>
|
prompts: () =>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
description: Schema.String.pipe(Schema.optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||||
|
outputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const ToolResultContent = Schema.Union([
|
export const ToolResultContent = Schema.Union([
|
||||||
|
|
@ -391,7 +392,13 @@ export const layer = Layer.effect(
|
||||||
} satisfies MCPClient.ElicitationHandler
|
} satisfies MCPClient.ElicitationHandler
|
||||||
|
|
||||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
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) =>
|
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
|
||||||
new Prompt({
|
new Prompt({
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export const layer = Layer.effectDiscard(
|
||||||
properties: schema.properties ?? {},
|
properties: schema.properties ?? {},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
|
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
|
|
|
||||||
40
packages/core/test/fixture/mcp-output-schema.ts
Normal file
40
packages/core/test/fixture/mcp-output-schema.ts
Normal 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())
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import path from "node:path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||||
import { testEffect } from "./lib/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 assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||||
|
|
@ -29,6 +31,11 @@ const mcp = Layer.mock(MCP.Service, {
|
||||||
name: "search",
|
name: "search",
|
||||||
description: "Search",
|
description: "Search",
|
||||||
inputSchema: { type: "object", properties: {} },
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
outputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { ok: { type: "boolean" } },
|
||||||
|
required: ["ok"],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
callTool: (input) =>
|
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", () =>
|
it.effect("waits for permission before calling an MCP tool", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue