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).
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 22:09:03 +02:00
1 parent 28517affd0
commit 79e0eca023
5 files changed
+244

No files matched your search

+1
View File
@@ -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",
+3
View File
@@ -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)
@@ -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);
});
});
+108
View File
@@ -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<string, unknown>;
/** 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<ListToolsResult>;
/**
* 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<string, McpToolDefinition>,
): void {
const handlers = (
server.server as unknown as {
_requestHandlers?: Map<string, ListToolsHandler>;
}
)._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"]
>,
}
: {}),
};
}),
};
},
);
}
+8
View File
@@ -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<string, McpToolDefinition>();
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,