mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:28:26 +00:00
fix(mcp): preserve metadata across paginated tools (#35500)
This commit is contained in:
parent
780c99bc2e
commit
50a762e7b9
3 changed files with 172 additions and 1 deletions
|
|
@ -1,4 +1,8 @@
|
|||
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 { 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"
|
||||
|
|
@ -72,6 +76,63 @@ test("MCP tool names match V1 sanitization", () => {
|
|||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", 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",
|
||||
},
|
||||
),
|
||||
)
|
||||
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
|
||||
Promise.resolve({
|
||||
content: [],
|
||||
structuredContent: { value: params.name === "first" ? 42 : 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
const client = new Client({ name: "pagination-test", version: "1.0.0" })
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
|
||||
|
||||
try {
|
||||
const first = await client.listTools()
|
||||
const second = await client.listTools({ cursor: first.nextCursor })
|
||||
expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
|
||||
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
|
||||
"Structured content does not match the tool's output schema",
|
||||
)
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()])
|
||||
}
|
||||
})
|
||||
|
||||
it.effect("waits for permission before calling an MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
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 { McpCatalog } from "@/mcp/catalog"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any
|
||||
|
||||
|
|
@ -45,3 +49,59 @@ describe("McpCatalog.convertTool", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", 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",
|
||||
},
|
||||
),
|
||||
)
|
||||
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
|
||||
Promise.resolve({
|
||||
content: [],
|
||||
structuredContent: { value: params.name === "first" ? 42 : 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
const client = new Client({ name: "pagination-test", version: "1.0.0" })
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
|
||||
|
||||
try {
|
||||
const tools = await Effect.runPromise(McpCatalog.defs(client))
|
||||
expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"])
|
||||
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
|
||||
"Structured content does not match the tool's output schema",
|
||||
)
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()])
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -112,6 +112,31 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1
|
|||
/**
|
||||
* After initialization has completed, this will be populated with the server's reported capabilities.
|
||||
*/
|
||||
@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol {
|
||||
* Called after listTools() to pre-compile validators for better performance.
|
||||
*/
|
||||
- cacheToolMetadata(tools) {
|
||||
- this._cachedToolOutputValidators.clear();
|
||||
- this._cachedKnownTaskTools.clear();
|
||||
- this._cachedRequiredTaskTools.clear();
|
||||
+ cacheToolMetadata(tools, reset = true) {
|
||||
+ if (reset) {
|
||||
+ this._cachedToolOutputValidators.clear();
|
||||
+ this._cachedKnownTaskTools.clear();
|
||||
+ this._cachedRequiredTaskTools.clear();
|
||||
+ }
|
||||
for (const tool of tools) {
|
||||
// If the tool has an outputSchema, create and cache the validator
|
||||
if (tool.outputSchema) {
|
||||
@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol {
|
||||
async listTools(params, options) {
|
||||
const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options);
|
||||
// Cache the tools and their output schemas for future validation
|
||||
- this.cacheToolMetadata(result.tools);
|
||||
+ this.cacheToolMetadata(result.tools, params?.cursor === undefined);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js
|
||||
index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644
|
||||
--- a/dist/cjs/client/streamableHttp.js
|
||||
|
|
@ -461,6 +486,31 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e
|
|||
/**
|
||||
* After initialization has completed, this will be populated with the server's reported capabilities.
|
||||
*/
|
||||
@@ -537,9 +543,11 @@ export class Client extends Protocol {
|
||||
* Called after listTools() to pre-compile validators for better performance.
|
||||
*/
|
||||
- cacheToolMetadata(tools) {
|
||||
- this._cachedToolOutputValidators.clear();
|
||||
- this._cachedKnownTaskTools.clear();
|
||||
- this._cachedRequiredTaskTools.clear();
|
||||
+ cacheToolMetadata(tools, reset = true) {
|
||||
+ if (reset) {
|
||||
+ this._cachedToolOutputValidators.clear();
|
||||
+ this._cachedKnownTaskTools.clear();
|
||||
+ this._cachedRequiredTaskTools.clear();
|
||||
+ }
|
||||
for (const tool of tools) {
|
||||
// If the tool has an outputSchema, create and cache the validator
|
||||
if (tool.outputSchema) {
|
||||
@@ -565,7 +573,7 @@ export class Client extends Protocol {
|
||||
async listTools(params, options) {
|
||||
const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options);
|
||||
// Cache the tools and their output schemas for future validation
|
||||
- this.cacheToolMetadata(result.tools);
|
||||
+ this.cacheToolMetadata(result.tools, params?.cursor === undefined);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js
|
||||
index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644
|
||||
--- a/dist/esm/client/streamableHttp.js
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue