From a3aa109edc5905895394fee1079135b67ec314ed Mon Sep 17 00:00:00 2001 From: Laurent Mwamba Date: Wed, 2 Sep 2026 13:43:23 -0700 Subject: [PATCH] fix draft-07 outputschema dialect from pinned sdk @modelcontextprotocol/sdk always reports "$schema": draft-07 on Zod-derived tool schemas with no way to configure it, which strict 2020-12-only client validators reject before a tool call ever reaches the server. Adds fixOutputSchemaDialect() to rewrite the dialect and wires it into pdf-server, whose display_pdf/read_pdf_bytes/save_pdf tools hit this. Upstream bug: https://github.com/modelcontextprotocol/typescript-sdk/issues/2721 --- examples/pdf-server/server.ts | 6 ++ src/server/index.examples.ts | 33 ++++++++++- src/server/index.test.ts | 74 +++++++++++++++++++++++ src/server/index.ts | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 93dedfdc9..7c5a16184 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -16,6 +16,7 @@ import path from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { + fixOutputSchemaDialect, registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, @@ -1275,6 +1276,11 @@ export function createServer(options: CreateServerOptions = {}): McpServer { const debug = options.debug ?? false; const disableInteract = !enableInteract; const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); + // Work around an upstream SDK bug where tool schemas always report a + // draft-07 $schema dialect, which strict 2020-12-only validators (e.g. + // Claude Desktop, Claude Code) reject: must run before any tool + // registration. See https://github.com/modelcontextprotocol/typescript-sdk/issues/2721 + fixOutputSchemaDialect(server); if (useClientRoots) { // Fetch roots on initialization and subscribe to changes diff --git a/src/server/index.examples.ts b/src/server/index.examples.ts index d583ae85c..35d0294c5 100644 --- a/src/server/index.examples.ts +++ b/src/server/index.examples.ts @@ -9,8 +9,8 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { - McpServer, ToolCallback, ReadResourceCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -19,6 +19,7 @@ import { registerAppTool, registerAppResource, getUiCapability, + fixOutputSchemaDialect, RESOURCE_MIME_TYPE, } from "./index.js"; @@ -289,3 +290,33 @@ function getUiCapability_checkSupport( }; //#endregion getUiCapability_checkSupport } + +/** + * Example: Work around the upstream SDK bug that always reports a draft-07 + * `$schema` dialect for Zod-based `inputSchema`/`outputSchema`. + */ +function fixOutputSchemaDialect_basicUsage() { + //#region fixOutputSchemaDialect_basicUsage + const server = new McpServer({ name: "my-server", version: "1.0.0" }); + fixOutputSchemaDialect(server); + + // Tool registration must happen after fixOutputSchemaDialect(server) above. + registerAppTool( + server, + "get-weather", + { + description: "Get current weather for a location", + inputSchema: { location: z.string() }, + outputSchema: { temp: z.number(), conditions: z.string() }, + _meta: { ui: { resourceUri: "ui://weather/view.html" } }, + }, + async ({ location }) => { + const weather = await fetchWeather(location); + return { + content: [{ type: "text", text: JSON.stringify(weather) }], + structuredContent: weather, + }; + }, + ); + //#endregion fixOutputSchemaDialect_basicUsage +} diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 1853cc05a..21919f504 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -5,9 +5,11 @@ import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE, getUiCapability, + fixOutputSchemaDialect, EXTENSION_ID, } from "./index"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; describe("registerAppTool", () => { it("should pass through config to server.registerTool", () => { @@ -356,3 +358,75 @@ describe("getUiCapability", () => { expect(getUiCapability(caps)).toBeUndefined(); }); }); + +describe("fixOutputSchemaDialect", () => { + it("should rewrite the draft-07 $schema on tools/list results", async () => { + let capturedHandler: ((...args: unknown[]) => Promise) | undefined; + + const mockServer = { + server: { + setRequestHandler: mock((_requestSchema: unknown, handler: unknown) => { + capturedHandler = handler as (...args: unknown[]) => Promise; + }), + }, + }; + + fixOutputSchemaDialect(mockServer as unknown as Pick); + + // Simulate McpServer registering its tools/list handler after the patch. + const fakeToolsListResult = { + tools: [ + { + name: "my-tool", + inputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + }, + outputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { result: { type: "string" } }, + }, + }, + ], + }; + const innerHandler = mock(async () => fakeToolsListResult); + mockServer.server.setRequestHandler(ListToolsRequestSchema, innerHandler); + + expect(capturedHandler).toBeDefined(); + const result = (await capturedHandler!()) as typeof fakeToolsListResult; + + expect(innerHandler).toHaveBeenCalledTimes(1); + expect(result.tools[0].inputSchema.$schema).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); + expect(result.tools[0].outputSchema?.$schema).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); + }); + + it("should pass through handlers for other request schemas unmodified", () => { + const OtherRequestSchema = { method: "other/thing" }; + const originalSetRequestHandler = mock( + (_requestSchema: unknown, _handler: unknown) => {}, + ); + + const mockServer = { + server: { + setRequestHandler: originalSetRequestHandler, + }, + }; + + fixOutputSchemaDialect(mockServer as unknown as Pick); + + const handler = async () => ({ ok: true }); + mockServer.server.setRequestHandler(OtherRequestSchema, handler); + + expect(originalSetRequestHandler).toHaveBeenCalledTimes(1); + expect(originalSetRequestHandler).toHaveBeenCalledWith( + OtherRequestSchema, + handler, + ); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index c90514acd..b41bb5359 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -53,9 +53,12 @@ import type { ZodRawShapeCompat, } from "@modelcontextprotocol/sdk/server/zod-compat.js"; import type { StandardSchemaWithJSON } from "../standard-schema"; +import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { ClientCapabilities, ReadResourceResult, + Tool, ToolAnnotations, } from "@modelcontextprotocol/sdk/types.js"; @@ -469,3 +472,107 @@ export function getUiCapability( | McpUiClientCapabilities | undefined; } + +/** + * JSON Schema `$schema` dialect URI that `@modelcontextprotocol/sdk` always emits for + * Zod-based tool schemas, regardless of which JSON Schema draft the schema actually + * conforms to. + */ +const DRAFT_07_DIALECT = "http://json-schema.org/draft-07/schema#"; + +/** + * JSON Schema dialect that MCP's `inputSchema`/`outputSchema` fields actually document + * (2020-12), and that strict validators expect to see in `$schema`. + */ +const DRAFT_2020_12_DIALECT = "https://json-schema.org/draft/2020-12/schema"; + +function rewriteDraft07Dialect(schema: unknown): void { + if ( + schema && + typeof schema === "object" && + (schema as { $schema?: unknown }).$schema === DRAFT_07_DIALECT + ) { + (schema as { $schema: string }).$schema = DRAFT_2020_12_DIALECT; + } +} + +/** + * Work around an `@modelcontextprotocol/sdk` bug where `tools/list` always reports + * `"$schema": "http://json-schema.org/draft-07/schema#"` on `inputSchema`/`outputSchema` + * for Zod-based tool schemas, even though the emitted schema is actually 2020-12. The SDK + * hardcodes this because it never passes a `target` through to `zod-to-json-schema` + * internals — there is no `registerTool`/`registerAppTool` option to configure it. + * + * Clients with a strict, dialect-aware 2020-12 output-schema validator (e.g. Claude + * Desktop, Claude Code) reject the mismatched `$schema` before a tool call ever reaches + * the server, breaking every tool in the list — not just the ones with an + * `outputSchema`. See {@link https://github.com/modelcontextprotocol/typescript-sdk/issues/2721} + * for the upstream tracking issue (open as of this writing, no released fix). + * + * This patches the low-level `Server.setRequestHandler` to intercept the handler that + * `McpServer` registers for `ListToolsRequestSchema`, rewriting the `$schema` dialect on + * every tool's `inputSchema`/`outputSchema` in the result before it's returned. + * + * `McpServer` only calls `server.setRequestHandler(ListToolsRequestSchema, ...)` once, + * lazily, the first time a tool is registered — so this must be called immediately after + * constructing `McpServer`, **before** registering any tools, or the patch will be + * installed too late to intercept the real handler. + * + * @param server - The MCP server instance (only its low-level `server` is used) + * + * @example Basic usage + * ```ts source="./index.examples.ts#fixOutputSchemaDialect_basicUsage" + * const server = new McpServer({ name: "my-server", version: "1.0.0" }); + * fixOutputSchemaDialect(server); + * + * // Tool registration must happen after fixOutputSchemaDialect(server) above. + * registerAppTool( + * server, + * "get-weather", + * { + * description: "Get current weather for a location", + * inputSchema: { location: z.string() }, + * outputSchema: { temp: z.number(), conditions: z.string() }, + * _meta: { ui: { resourceUri: "ui://weather/view.html" } }, + * }, + * async ({ location }) => { + * const weather = await fetchWeather(location); + * return { + * content: [{ type: "text", text: JSON.stringify(weather) }], + * structuredContent: weather, + * }; + * }, + * ); + * ``` + */ +export function fixOutputSchemaDialect( + server: Pick, +): void { + const lowLevelServer: Server = server.server; + const originalSetRequestHandler = + lowLevelServer.setRequestHandler.bind(lowLevelServer); + + lowLevelServer.setRequestHandler = (( + requestSchema: T, + handler: (...args: unknown[]) => unknown, + ) => { + if ((requestSchema as unknown) !== ListToolsRequestSchema) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return originalSetRequestHandler(requestSchema as any, handler as any); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return originalSetRequestHandler( + requestSchema as any, + (async (...args: unknown[]) => { + const result = (await handler(...args)) as { tools?: Tool[] }; + for (const tool of result?.tools ?? []) { + rewriteDraft07Dialect(tool.inputSchema); + rewriteDraft07Dialect(tool.outputSchema); + } + return result; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + }) as typeof lowLevelServer.setRequestHandler; +}