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,