diff --git a/clients/web/src/test/core/xMcpHeader.test.ts b/clients/web/src/test/core/xMcpHeader.test.ts index 07235c8925..596d72f7cc 100644 --- a/clients/web/src/test/core/xMcpHeader.test.ts +++ b/clients/web/src/test/core/xMcpHeader.test.ts @@ -52,13 +52,12 @@ describe("scanXMcpHeaderDeclarations", () => { }); }); - it("accepts integer, boolean, and number typed properties", () => { + it("accepts integer and boolean typed properties", () => { const scan = scanXMcpHeaderDeclarations({ type: "object", properties: { count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" }, flag: { type: "boolean", [X_MCP_HEADER_KEY]: "Flag" }, - ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, }, }); expect(scan.valid).toBe(true); @@ -66,11 +65,50 @@ describe("scanXMcpHeaderDeclarations", () => { expect(scan.declarations.map((d) => d.headerName)).toEqual([ "Count", "Flag", - "Ratio", ]); } }); + it("rejects a number-typed property, as the SDK does from 2.4.0", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" }, + ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) { + expect(scan.reason).toBe( + "ratio: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got number", + ); + } + }); + + it("reports a number-typed fault in preference to a later fault", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, + obj: { type: "object", [X_MCP_HEADER_KEY]: "Obj" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("got number"); + }); + + it("reports a fault that ends the walk before any number-typed one", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + obj: { type: "object", [X_MCP_HEADER_KEY]: "Obj" }, + ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("got object"); + }); + it("collects declarations nested at any depth via properties chains", () => { const scan = scanXMcpHeaderDeclarations({ type: "object", @@ -304,7 +342,6 @@ describe("buildMcpParamHeaders", () => { owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" }, count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" }, flag: { type: "boolean", [X_MCP_HEADER_KEY]: "Flag" }, - ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, }, }); @@ -319,12 +356,10 @@ describe("buildMcpParamHeaders", () => { buildMcpParamHeaders(decls, { count: 42, flag: false, - ratio: 3.5, }), ).toEqual({ [`${P}Count`]: "42", [`${P}Flag`]: "false", - [`${P}Ratio`]: "3.5", }); expect(buildMcpParamHeaders(decls, { flag: true })).toEqual({ [`${P}Flag`]: "true", @@ -346,8 +381,8 @@ describe("buildMcpParamHeaders", () => { }); it("omits non-finite numbers and unsafe integers", () => { - expect(buildMcpParamHeaders(decls, { ratio: Infinity })).toEqual({}); - expect(buildMcpParamHeaders(decls, { ratio: NaN })).toEqual({}); + expect(buildMcpParamHeaders(decls, { count: Infinity })).toEqual({}); + expect(buildMcpParamHeaders(decls, { count: NaN })).toEqual({}); expect( buildMcpParamHeaders(decls, { count: Number.MAX_SAFE_INTEGER + 2 }), ).toEqual({}); diff --git a/core/json/xMcpHeader.ts b/core/json/xMcpHeader.ts index 739eb6a2b9..0364357ec5 100644 --- a/core/json/xMcpHeader.ts +++ b/core/json/xMcpHeader.ts @@ -33,20 +33,18 @@ export const MCP_PARAM_HEADER_PREFIX = "Mcp-Param-"; const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; /** - * JSON Schema `type` values the spec admits on an `x-mcp-header` property. - * - * The spec text names `integer`, `string`, `boolean` and explicitly excludes - * `number`. The published conformance referee at the pinned release ships its - * `http-custom-headers` scenario with `type: "number"` `x-mcp-header` params and - * expects the client to mirror them, so the SDK accepts `number` for the - * conformance gate; this port matches the SDK so exclusions agree exactly. - * Everything else (`object`, `array`, `null`, absent) is rejected. + * JSON Schema `type` values the spec admits on an `x-mcp-header` property: + * `integer`, `string` and `boolean`. `number` is explicitly not permitted + * (2026-07-28 Streamable HTTP, "Schema Extension"), so a tool that annotates a + * `number`-typed property is invalid. The SDK accepted `number` through 2.3.x + * to satisfy an older conformance fixture and rejects it from 2.4.0; this port + * follows it so exclusions agree exactly. Everything else (`object`, `array`, + * `null`, absent) is rejected too. */ const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set([ "string", "integer", "boolean", - "number", ]); /** @@ -113,7 +111,8 @@ function isRecord(node: unknown): node is Record { /** * Scan a tool's `inputSchema` for `x-mcp-header` declarations and validate every * constraint the spec places on them. Returns the collected declarations - * (possibly empty) on success, or the first violated constraint's reason. + * (possibly empty) on success, or the first violated constraint's reason — the + * first `number`-typed annotation's when there is one, as the SDK reports it. * * The walk descends through `properties` at any depth (the spec's "any nesting * depth" clause). The static-reachability MUST is enforced structurally: every @@ -130,6 +129,11 @@ export function scanXMcpHeaderDeclarations( ): XMcpHeaderScan { const declarations: XMcpHeaderDeclaration[] = []; const seenLower = new Map(); + // The SDK records a `number`-typed annotation's fault and keeps walking + // (its server still checks those headers), then reports that fault in + // preference to any later one. Mirrored here so the excluded tool's reason + // matches; the declarations it also returns serve only that server check. + let numberFault: string | undefined; const visit = ( node: unknown, @@ -152,7 +156,9 @@ export function scanXMcpHeaderDeclarations( } const type = typeof schema.type === "string" ? schema.type : undefined; if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) { - return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ""}`; + const fault = `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ""}`; + if (type !== "number") return fault; + numberFault ??= fault; } const lower = raw.toLowerCase(); const prior = seenLower.get(lower); @@ -188,10 +194,10 @@ export function scanXMcpHeaderDeclarations( return undefined; }; - const fault = visit(inputSchema, [], true); + const fault = visit(inputSchema, [], true) ?? numberFault; return fault === undefined ? { valid: true, declarations } - : { valid: false, reason: fault }; + : { valid: false, reason: numberFault ?? fault }; } /** diff --git a/package-lock.json b/package-lock.json index c2a4ef0a0a..e5e41033fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.3.1", - "@modelcontextprotocol/core": "2.3.1", + "@modelcontextprotocol/client": "2.4.0", + "@modelcontextprotocol/core": "2.4.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/ext-tasks": "^0.2.2", - "@modelcontextprotocol/server": "2.3.1", + "@modelcontextprotocol/server": "2.4.0", "@napi-rs/keyring": "^2.1.1", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -40,7 +40,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@modelcontextprotocol/server-legacy": "2.3.1", + "@modelcontextprotocol/server-legacy": "2.4.0", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", "@vitest/coverage-v8": "4.1.11", @@ -646,12 +646,12 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/client": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", - "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz", + "integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.3.1", + "@modelcontextprotocol/core": "2.4.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", @@ -664,9 +664,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", - "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz", + "integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==", "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" @@ -726,12 +726,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", - "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz", + "integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.3.1", + "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" }, "engines": { @@ -739,14 +739,14 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.3.1.tgz", - "integrity": "sha512-+cuB+hl7s8DiFXIt13le3cD2wZPOeDYwlycAkRMPljqqNbOLekGRiyslx8WXfqaXSCveS1eDD8/B8rmxz9BZFQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.4.0.tgz", + "integrity": "sha512-xWq1YTZiLZltEXp/NJhsW7IvpuowrapEFuriAz3a86CIwMnt7+r8EaG/7xtWi7aqLzhtplOJJffUE5YIcUT+Aw==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "dev": true, "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.3.1", + "@modelcontextprotocol/core": "2.4.0", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", diff --git a/package.json b/package.json index 8243224cb9..b5b3f9c3c0 100644 --- a/package.json +++ b/package.json @@ -125,11 +125,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.3.1", - "@modelcontextprotocol/core": "2.3.1", + "@modelcontextprotocol/client": "2.4.0", + "@modelcontextprotocol/core": "2.4.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/ext-tasks": "^0.2.2", - "@modelcontextprotocol/server": "2.3.1", + "@modelcontextprotocol/server": "2.4.0", "@napi-rs/keyring": "^2.1.1", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -155,7 +155,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@modelcontextprotocol/server-legacy": "2.3.1", + "@modelcontextprotocol/server-legacy": "2.4.0", "@types/node": "^24.12.4", "@types/proper-lockfile": "^4.1.4", "@vitest/coverage-v8": "4.1.11",