diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 387c8a11582..0f51129ac05 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -474,8 +474,8 @@ const OPTIONAL_TOOL_FIELDS = { /** * Returns why a listed tool cannot be used, or undefined when it can. A malformed optional field is - * deleted instead, keeping the tool. Schemas are kept as the server sent them: the legacy SDK schema - * walked every property schema, which cost most of the time on large catalogs. + * deleted instead, keeping the tool. Property schemas are not walked: the legacy SDK schema did, which + * cost most of the time on large catalogs. */ function checkTool(tool: unknown) { if (!isRecord(tool)) return "not an object" @@ -483,6 +483,13 @@ function checkTool(tool: unknown) { if (!isRecord(tool.inputSchema)) return "inputSchema is not an object" if (tool.inputSchema.type !== undefined && tool.inputSchema.type !== "object") return 'inputSchema type is not "object"' + // Providers that check tool parameters against the JSON Schema metaschema reject the whole request + // over a malformed top level, so drop it; nested property schemas are left as sent. + if (tool.inputSchema.properties !== undefined && !isRecord(tool.inputSchema.properties)) + delete tool.inputSchema.properties + const required = tool.inputSchema.required + if (required !== undefined && !(Array.isArray(required) && required.every((key) => typeof key === "string"))) + delete tool.inputSchema.required for (const [key, kind] of Object.entries(OPTIONAL_TOOL_FIELDS)) { const value = tool[key] if (value === undefined) continue diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 1efbfaf991e..45cf2bb4443 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -99,8 +99,8 @@ function resourceServer( urlElicitation?: boolean /** * Ahead of the valid tools, list entries that cannot be used (not an object, no name, an input - * schema that is not an object), one whose `required` is a string, which only the 2026-07-28 SDK - * schema accepts, and one with a malformed description. + * schema that is not an object), one whose input schema has a string `required`, and one with a + * malformed description. */ malformedTool?: boolean /** Legacy servers give up on the URL elicitation after this many milliseconds. */ @@ -160,7 +160,7 @@ function resourceServer( { inputSchema: { type: "object" } }, { name: "no-schema" }, { name: "malformed", inputSchema: { type: "string" } }, - { name: "modern-only", inputSchema: { type: "object", required: "text" } }, + { name: "bad-required", inputSchema: { type: "object", required: "text" } }, { name: "bad-description", description: 42, inputSchema: { type: "object" } }, ] as unknown as McpClient.Tool[]) : []), @@ -1213,10 +1213,13 @@ describe.each([ ) const tools = yield* connection.tools() - expect(tools.map((tool) => tool.name)).toEqual(["modern-only", "bad-description", "echo"]) - // A malformed optional field is dropped, not the tool. - expect(tools[1]).toEqual({ name: "bad-description", inputSchema: { type: "object" } }) - for (const name of ["modern-only", "bad-description", "echo"]) { + expect(tools.map((tool) => tool.name)).toEqual(["bad-required", "bad-description", "echo"]) + // A malformed optional field or top-level `required` is dropped, not the tool. + expect(tools.slice(0, 2)).toEqual([ + { name: "bad-required", inputSchema: { type: "object" } }, + { name: "bad-description", inputSchema: { type: "object" } }, + ]) + for (const name of ["bad-required", "bad-description", "echo"]) { yield* connection.callTool({ name, args: { text: "hi" } }) expect(server.state.toolCalls.at(-1)).toMatchObject({ name, arguments: { text: "hi" } }) }