From 79e0eca023ed76b7e082cbd0938fbd586883c670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:09:03 +0200 Subject: [PATCH] fix(mcp): advertise tool schemas as JSON Schema 2020-12 The SDK renders every tool's Zod shape with a fixed draft-07 target and stamps "$schema": "http://json-schema.org/draft-07/schema#" on each inputSchema and outputSchema. Clients that validate with a 2020-12-only validator refuse that dialect when the tool list arrives, so every tool was unusable there (#1170). Wrap the tools/list handler once, after the last tool is registered, and re-render each advertised schema from the same Zod shape with the draft-2020-12 target, without a $schema key. A schema with no $schema is 2020-12 under the MCP spec and still compiles in a draft-07 client. All other fields of a tool entry pass through unchanged, and the SDK keeps validating structuredContent against the Zod shape, so tool results are unaffected. For the current surface the 54 rendered schemas differ from the previous ones only by the dropped key. The new guard reads tools/list through a real client for a write-scoped session, rejects any draft-07 marker or construct, and compiles every schema with Ajv's 2020-12 build (ajv added as a dev dependency at the version the SDK already resolves). --- package.json | 1 + pnpm-lock.yaml | 3 + .../mcp/__tests__/tool-schema-dialect.test.ts | 124 ++++++++++++++++++ src/lib/mcp/schema-dialect.ts | 108 +++++++++++++++ src/lib/mcp/server.ts | 8 ++ 5 files changed, 244 insertions(+) create mode 100644 src/lib/mcp/__tests__/tool-schema-dialect.test.ts create mode 100644 src/lib/mcp/schema-dialect.ts diff --git a/package.json b/package.json index dceafe607..022eeaa01 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.5", + "ajv": "^8.20.0", "eslint": "^9", "eslint-config-next": "16.3.8", "knip": "^6.32.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9721f633b..d7aced555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.11) + ajv: + specifier: ^8.20.0 + version: 8.20.0 eslint: specifier: ^9 version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0) diff --git a/src/lib/mcp/__tests__/tool-schema-dialect.test.ts b/src/lib/mcp/__tests__/tool-schema-dialect.test.ts new file mode 100644 index 000000000..15edcdfda --- /dev/null +++ b/src/lib/mcp/__tests__/tool-schema-dialect.test.ts @@ -0,0 +1,124 @@ +/** + * Guard: every schema `tools/list` advertises is valid JSON Schema 2020-12. + * + * The SDK converts each Zod shape with a hard-coded draft-07 target, so every + * `inputSchema` and `outputSchema` used to carry + * `"$schema": "http://json-schema.org/draft-07/schema#"`. A client that + * validates with a 2020-12-only allowlist refuses the dialect at registration + * and every tool becomes unusable (#1170). The server now advertises 2020-12 + * with no `$schema` key — the MCP default dialect — so a strict client and a + * draft-07 client both accept it. + * + * The list is read through a real SDK client over an in-memory transport, so + * the assertion is on the wire, not on the registry. A write-scoped session is + * used so the write tools are covered too. + */ +import { describe, it, expect, vi } from "vitest"; +import Ajv2020 from "ajv/dist/2020"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +process.env.APP_URL = "http://localhost"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../server"; +import { MCP_TOOLS } from "../tools"; +import { MCP_WRITE_TOOLS } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +const ctx: McpAuthContext = { + userId: "u-1", + tokenId: "t-1", + scopes: ["health:read", "health:write"], + binding: "u-1:t-1", + canRead: true, + canWrite: true, +}; + +async function listTools() { + const server = createMcpServer(ctx); + const client = new Client({ name: "dialect-test", version: "1.0.0" }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverSide), client.connect(clientSide)]); + const { tools } = await client.listTools(); + await client.close(); + await server.close(); + return tools; +} + +/** Every draft-07-only construct, anywhere in a schema tree. */ +function draft07Constructs(node: unknown, path = "$"): string[] { + if (Array.isArray(node)) { + return node.flatMap((v, i) => draft07Constructs(v, `${path}[${i}]`)); + } + if (!node || typeof node !== "object") return []; + const found: string[] = []; + for (const [key, value] of Object.entries(node)) { + const at = `${path}.${key}`; + if ( + key === "$schema" && + value !== "https://json-schema.org/draft/2020-12/schema" + ) { + found.push(`${at} = ${String(value)}`); + } + if (key === "definitions") found.push(at); + if (key === "dependencies") found.push(at); + if (key === "additionalItems") found.push(at); + if (key === "items" && Array.isArray(value)) found.push(`${at} (array)`); + if ( + key === "$ref" && + typeof value === "string" && + value.includes("/definitions/") + ) { + found.push(`${at} = ${value}`); + } + found.push(...draft07Constructs(value, at)); + } + return found; +} + +describe("tools/list advertises JSON Schema 2020-12", () => { + it("advertises every read and write tool", async () => { + const tools = await listTools(); + expect(tools.map((t) => t.name).sort()).toEqual( + [...MCP_TOOLS, ...MCP_WRITE_TOOLS].map((t) => t.name).sort(), + ); + }); + + it("carries no draft-07 marker or construct anywhere", async () => { + const tools = await listTools(); + expect(JSON.stringify(tools)).not.toContain("draft-07"); + const offenders = tools.flatMap((tool) => [ + ...draft07Constructs(tool.inputSchema, `${tool.name}.inputSchema`), + ...(tool.outputSchema + ? draft07Constructs(tool.outputSchema, `${tool.name}.outputSchema`) + : []), + ]); + expect(offenders).toEqual([]); + }); + + it("compiles every input and output schema under a 2020-12 validator", async () => { + const tools = await listTools(); + const ajv = new Ajv2020({ strict: false, validateSchema: true }); + let compiled = 0; + for (const tool of tools) { + for (const [kind, schema] of [ + ["inputSchema", tool.inputSchema], + ["outputSchema", tool.outputSchema], + ] as const) { + if (!schema) continue; + expect( + ajv.validateSchema(schema), + `${tool.name}.${kind}: ${ajv.errorsText()}`, + ).toBe(true); + expect(() => ajv.compile(schema), `${tool.name}.${kind}`).not.toThrow(); + compiled++; + } + } + // Every tool has an inputSchema, and the surface declares output shapes; + // a count of zero would mean the loop above proved nothing. + expect(compiled).toBeGreaterThan(tools.length); + }); +}); diff --git a/src/lib/mcp/schema-dialect.ts b/src/lib/mcp/schema-dialect.ts new file mode 100644 index 000000000..ac97f3630 --- /dev/null +++ b/src/lib/mcp/schema-dialect.ts @@ -0,0 +1,108 @@ +/** + * The JSON Schema dialect `tools/list` advertises. + * + * The SDK converts every tool's Zod shape with a hard-coded draft-07 target and + * stamps `"$schema": "http://json-schema.org/draft-07/schema#"` on each + * `inputSchema` and `outputSchema`. Clients that validate with a 2020-12-only + * validator refuse that dialect when the tool list arrives, so every tool is + * unusable there, before a call ever reaches the server (#1170). + * + * This module re-renders each advertised schema from the same Zod shape with + * the 2020-12 target (`$defs`, `prefixItems`, …) and drops the `$schema` key. + * A schema without `$schema` is 2020-12 under the MCP spec, and a draft-07 + * client still compiles it, so omitting the key serves both. Everything else + * the SDK puts on a tool entry is kept as it is. + * + * Only the advertised copy changes. The SDK validates `structuredContent` + * against the Zod shape itself, never against this JSON, so result validation + * is untouched. Prompts advertise an argument list rather than a schema, and + * resource templates carry no schema, so tools are the whole surface. + */ +import { z } from "zod/v4"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ListToolsRequestSchema, + type ListToolsRequest, + type ListToolsResult, +} from "@modelcontextprotocol/sdk/types.js"; + +import type { McpToolDefinition } from "./tools"; + +type JsonSchemaObject = Record; + +/** Render one Zod raw shape as a 2020-12 schema with no `$schema` key. */ +export function toAdvertisedJsonSchema( + shape: z.ZodRawShape, + io: "input" | "output", +): JsonSchemaObject { + const rendered = z.toJSONSchema(z.object(shape), { + target: "draft-2020-12", + io, + }) as JsonSchemaObject; + delete rendered.$schema; + return rendered; +} + +type ListToolsHandler = ( + request: ListToolsRequest, + extra: unknown, +) => Promise; + +/** + * Wrap the SDK's `tools/list` handler so every advertised schema is 2020-12. + * Call once, after every tool is registered. `tools` maps each registered name + * to its definition; a listed tool missing from it is a wiring error and + * throws rather than slipping through in the SDK's dialect. + * + * The SDK exposes no way to read its own handler, so it is taken from the + * protocol's handler map. When no tool was registered there is no handler and + * nothing to rewrite. `tool-schema-dialect.test.ts` reads the list through a + * real client, so an SDK change that moves the map fails there. + */ +export function advertiseToolSchemasAs2020( + server: McpServer, + tools: ReadonlyMap, +): void { + const handlers = ( + server.server as unknown as { + _requestHandlers?: Map; + } + )._requestHandlers; + const sdkList = handlers?.get("tools/list"); + if (!sdkList) return; + + server.server.setRequestHandler( + ListToolsRequestSchema, + async (request, extra) => { + const result = await sdkList(request, extra); + return { + ...result, + tools: result.tools.map((entry) => { + const definition = tools.get(entry.name); + if (!definition) { + throw new Error( + `tools/list: no definition registered for "${entry.name}"`, + ); + } + return { + ...entry, + inputSchema: toAdvertisedJsonSchema( + definition.inputShape, + "input", + ) as ListToolsResult["tools"][number]["inputSchema"], + ...(definition.outputShape + ? { + outputSchema: toAdvertisedJsonSchema( + definition.outputShape, + "output", + ) as NonNullable< + ListToolsResult["tools"][number]["outputSchema"] + >, + } + : {}), + }; + }), + }; + }, + ); +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index fd2ac69e3..19826bde9 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -26,6 +26,7 @@ import { MCP_WRITE_TOOLS } from "./write-tools"; import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "./resources"; import { MCP_PROMPTS } from "./prompts"; import { resolveBaseOrigin } from "./oauth/config"; +import { advertiseToolSchemasAs2020 } from "./schema-dialect"; export const MCP_SERVER_NAME = "healthlog"; export const MCP_SERVER_VERSION = "1.0.0"; @@ -84,7 +85,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { // Register one tool definition. Shared by the read registry and the // write-scoped registry so the wire shape can never fork between them. + const registeredTools = new Map(); const registerTool = (tool: McpToolDefinition) => { + registeredTools.set(tool.name, tool); server.registerTool( tool.name, { @@ -130,6 +133,11 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { } } + // Advertise every tool schema as JSON Schema 2020-12 instead of the SDK's + // draft-07, which strict clients refuse (#1170). After the last + // registerTool, so the wrap covers every tool this session sees. + advertiseToolSchemasAs2020(server, registeredTools); + for (const prompt of MCP_PROMPTS) { server.registerPrompt( prompt.name,