Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion src/server/index.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +19,7 @@ import {
registerAppTool,
registerAppResource,
getUiCapability,
fixOutputSchemaDialect,
RESOURCE_MIME_TYPE,
} from "./index.js";

Expand Down Expand Up @@ -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
}
74 changes: 74 additions & 0 deletions src/server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<unknown>) | undefined;

const mockServer = {
server: {
setRequestHandler: mock((_requestSchema: unknown, handler: unknown) => {
capturedHandler = handler as (...args: unknown[]) => Promise<unknown>;
}),
},
};

fixOutputSchemaDialect(mockServer as unknown as Pick<McpServer, "server">);

// 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<McpServer, "server">);

const handler = async () => ({ ok: true });
mockServer.server.setRequestHandler(OtherRequestSchema, handler);

expect(originalSetRequestHandler).toHaveBeenCalledTimes(1);
expect(originalSetRequestHandler).toHaveBeenCalledWith(
OtherRequestSchema,
handler,
);
});
});
107 changes: 107 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<McpServer, "server">,
): void {
const lowLevelServer: Server = server.server;
const originalSetRequestHandler =
lowLevelServer.setRequestHandler.bind(lowLevelServer);

lowLevelServer.setRequestHandler = (<T extends { method: unknown }>(
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;
}