diff --git a/src/scenarios/client/http-custom-headers.test.ts b/src/scenarios/client/http-custom-headers.test.ts index 28341918..ba0d3608 100644 --- a/src/scenarios/client/http-custom-headers.test.ts +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -69,71 +69,106 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { } }); - it('maps each parameter kind to its requirement ID on a conforming tool call', async () => { - const scenario = new HttpCustomHeadersScenario(); - const { serverUrl } = await scenario.start(testScenarioContext()); - try { - const nonAscii = 'Hello, 世界'; - const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64'); - await post( - serverUrl, - { - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'test_custom_headers', - arguments: { - region: 'us-west1', - priority: 42, - non_ascii_val: nonAscii, - query: 'SELECT 1' + it.each([false, true])( + 'isolates range probe rejection (rejected: %s) from ordinary header checks', + async (rejectProbe) => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const nonAscii = 'Hello, 世界'; + const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64'); + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_custom_headers', + arguments: { + region: 'us-west1', + priority: 42, + non_ascii_val: nonAscii, + query: 'SELECT 1' + } } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers', + 'Mcp-Param-Region': 'us-west1', + 'Mcp-Param-Priority': '42', + 'Mcp-Param-NonAscii': `=?base64?${nonAsciiB64}?=` } - }, - { - 'Mcp-Method': 'tools/call', - 'Mcp-Name': 'test_custom_headers', - 'Mcp-Param-Region': 'us-west1', - 'Mcp-Param-Priority': '42', - 'Mcp-Param-NonAscii': `=?base64?${nonAsciiB64}?=` - } - ); - await post( - serverUrl, - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'test_custom_headers_null', - arguments: { - region: 'us-east1', - priority: 1, - verbose: null, - query: 'SELECT 1' + ); + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'test_custom_headers_null', + arguments: { + region: 'us-east1', + priority: 1, + verbose: null, + query: 'SELECT 1' + } } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers_null', + 'Mcp-Param-Region': 'us-east1', + 'Mcp-Param-Priority': '1' + // Mcp-Param-Verbose deliberately omitted: value is null } - }, - { - 'Mcp-Method': 'tools/call', - 'Mcp-Name': 'test_custom_headers_null', - 'Mcp-Param-Region': 'us-east1', - 'Mcp-Param-Priority': '1' - // Mcp-Param-Verbose deliberately omitted: value is null + ); + + if (!rejectProbe) { + await post(serverUrl, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'test_custom_headers_unsafe_integer', + arguments: { unsafe_integer_val: 9007199254740992 } + } + }); } - ); - const checks = scenario.getChecks(); - for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { - const statuses = statusesFor(checks, id); - expect(statuses.length, id).toBeGreaterThan(0); - expect(statuses, id).not.toContain('FAILURE'); + const checks = scenario.getChecks(); + expect(idsOf(checks)).toEqual( + new Set(CUSTOM_HEADERS_DECLARED_CHECK_IDS) + ); + for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { + const statuses = statusesFor(checks, id); + if ( + rejectProbe && + id === 'sep-2243-x-mcp-header-integer-safe-range' + ) { + expect(statuses).toEqual(['FAILURE']); + const check = checks.find((c) => c.id === id)!; + expect(check.details?.untestable).toBe(true); + expect(check.errorMessage).toContain( + 'test_custom_headers_unsafe_integer' + ); + expect(check.errorMessage).toContain( + 'rejected the argument locally' + ); + continue; + } + expect(statuses.length, id).toBeGreaterThan(0); + expect(statuses, id).not.toContain('FAILURE'); + } + const checkCount = checks.length; + expect(scenario.getChecks()).toHaveLength(checkCount); + } finally { + await scenario.stop(); } - } finally { - await scenario.stop(); } - }); + ); it('FAILs client-mirrors-designated-params when an annotated header is missing', async () => { const scenario = new HttpCustomHeadersScenario(); @@ -229,6 +264,16 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { : {} ); + await post(serverUrl, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'test_custom_headers_unsafe_integer', + arguments: { unsafe_integer_val: 9007199254740992 } + } + }); + expect(toolsList.body.result.ttlMs).toBeGreaterThan(0); const checks = scenario.getChecks(); for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { @@ -240,6 +285,138 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { await scenario.stop(); } }); + + it('FAILs safe-integer-range when a client mirrors an out-of-range integer header', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_custom_headers_unsafe_integer', + arguments: { unsafe_integer_val: 9007199254740992 } + } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers_unsafe_integer', + 'Mcp-Param-UnsafeInteger': '9007199254740992' + } + ); + const checks = scenario.getChecks(); + expect( + statusesFor(checks, 'sep-2243-x-mcp-header-integer-safe-range') + ).toContain('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('PASSes safe-integer-range when a client omits the out-of-range integer header', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_custom_headers_unsafe_integer', + arguments: { unsafe_integer_val: 9007199254740992 } + } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers_unsafe_integer' + // Mcp-Param-UnsafeInteger is deliberately omitted + } + ); + const checks = scenario.getChecks(); + expect( + statusesFor(checks, 'sep-2243-x-mcp-header-integer-safe-range') + ).toContain('SUCCESS'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('HttpCustomHeadersScenario range probe', () => { + it('advertises a dedicated integer tool and schedules it after the ordinary calls', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl, context } = await scenario.start(testScenarioContext()); + try { + const toolCalls = context!.toolCalls as Array<{ + name: string; + arguments: Record; + }>; + expect(toolCalls.map((call) => call.name)).toEqual([ + 'test_custom_headers', + 'test_custom_headers_null', + 'test_custom_headers_unsafe_integer' + ]); + expect(toolCalls[0].arguments).not.toHaveProperty('unsafe_integer_val'); + expect(toolCalls[2].arguments).toEqual({ + unsafe_integer_val: 9007199254740992 + }); + const listed = await postJson(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list' + }); + const tools = listed.result.tools; + expect(tools[0].inputSchema.properties).not.toHaveProperty( + 'unsafe_integer_val' + ); + expect(tools[2]).toMatchObject({ + name: 'test_custom_headers_unsafe_integer', + inputSchema: { + properties: { + unsafe_integer_val: { + type: 'integer', + 'x-mcp-header': 'UnsafeInteger' + } + }, + required: ['unsafe_integer_val'] + } + }); + } finally { + await scenario.stop(); + } + }); + + it.each([undefined, null, 42, '9007199254740992'])( + 'does not pass when the client changes or omits the probe argument (%s)', + async (value) => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_custom_headers_unsafe_integer', + arguments: { unsafe_integer_val: value } + } + }); + const checks = scenario + .getChecks() + .filter((c) => c.id === 'sep-2243-x-mcp-header-integer-safe-range'); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('FAILURE'); + expect(checks[0].details?.untestable).toBe(true); + } finally { + await scenario.stop(); + } + } + ); }); describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => { diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index 062efd72..462a1295 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -14,6 +14,7 @@ import type { ScenarioContext } from '../../mock-server'; import http from 'http'; import { ScenarioUrls, ConformanceCheck } from '../../types.js'; import { BaseHttpScenario } from './http-base.js'; +import { untestableCheck } from '../untestable.js'; const SPEC_REFERENCE_CUSTOM = { id: 'SEP-2243-Custom-Headers', @@ -42,7 +43,8 @@ export const CUSTOM_HEADERS_DECLARED_CHECK_IDS = [ 'sep-2243-client-mirrors-designated-params', 'sep-2243-client-encode-values', 'sep-2243-client-base64-unsafe', - 'sep-2243-client-omit-null' + 'sep-2243-client-omit-null', + 'sep-2243-x-mcp-header-integer-safe-range' ] as const; /** @@ -223,6 +225,12 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { verbose: null, query: 'SELECT 1' } + }, + // Keep the range probe last: a client may reject this argument before + // sending the request, but must still exercise the ordinary checks. + { + name: 'test_custom_headers_unsafe_integer', + arguments: { unsafe_integer_val: 9007199254740992 } } ] }; @@ -235,6 +243,18 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { // calls the annotated tools. The `some()` guard makes this idempotent. for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { if (this.checks.some((c) => c.id === id)) continue; + if (id === 'sep-2243-x-mcp-header-integer-safe-range') { + this.checks.push( + untestableCheck( + id, + 'ClientCustomHeaderSafeIntegerRange', + 'Out-of-range integer arguments are not mirrored into Mcp-Param headers', + 'Client did not send test_custom_headers_unsafe_integer with unsafe_integer_val = 9007199254740992. It may have rejected the argument locally; the server cannot distinguish rejection from a skipped probe.', + [SPEC_REFERENCE_TOOL_DEF] + ) + ); + continue; + } const missingNullCall = id === 'sep-2243-client-omit-null' && !this.nullToolCallReceived; this.checks.push({ @@ -410,6 +430,21 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { }, required: ['region', 'priority', 'query'] } + }, + { + name: 'test_custom_headers_unsafe_integer', + description: + 'A separate probe for out-of-range integer header values', + inputSchema: { + type: 'object', + properties: { + unsafe_integer_val: { + type: 'integer', + 'x-mcp-header': 'UnsafeInteger' + } + }, + required: ['unsafe_integer_val'] + } } ] } @@ -584,6 +619,41 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { : undefined, specReferences: [SPEC_REFERENCE_CUSTOM] }); + } else if (toolName === 'test_custom_headers_unsafe_integer') { + const unsafeIntegerHeader = req.headers['mcp-param-unsafeinteger']; + if (args.unsafe_integer_val !== 9007199254740992) { + this.checks.push( + untestableCheck( + 'sep-2243-x-mcp-header-integer-safe-range', + 'ClientCustomHeaderSafeIntegerRange', + 'Out-of-range integer arguments are not mirrored into Mcp-Param headers', + 'Client called test_custom_headers_unsafe_integer without the requested unsafe_integer_val = 9007199254740992.', + [SPEC_REFERENCE_TOOL_DEF] + ) + ); + } else { + // This checks only the on-wire range constraint. Whether an invalid + // argument must cause a local error instead of header omission remains + // an open spec question in #445; SUCCESS does not settle that question. + this.checks.push({ + id: 'sep-2243-x-mcp-header-integer-safe-range', + name: 'ClientCustomHeaderSafeIntegerRange', + description: + 'Out-of-range integer arguments are not mirrored into Mcp-Param headers', + status: unsafeIntegerHeader === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + unsafeIntegerHeader !== undefined + ? `Client sent Mcp-Param-UnsafeInteger '${unsafeIntegerHeader}' for out-of-range argument 9007199254740992. Integer values MUST be within the safe range (-2^53+1 to 2^53-1).` + : undefined, + specReferences: [SPEC_REFERENCE_TOOL_DEF, SPEC_REFERENCE_CUSTOM], + details: { + headerName: 'Mcp-Param-UnsafeInteger', + rawHeaderValue: unsafeIntegerHeader, + bodyValue: args.unsafe_integer_val + } + }); + } } else if (toolName === 'test_custom_headers_null') { this.nullToolCallReceived = true; diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml index ddb06ffb..9fcf9462 100644 --- a/src/seps/sep-2243.yaml +++ b/src/seps/sep-2243.yaml @@ -25,6 +25,9 @@ requirements: - check: sep-2243-x-mcp-header-primitive-only text: 'x-mcp-header MUST only be applied to parameters with primitive types (integer, string, boolean). Parameters with type `number` are not permitted.' url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-integer-safe-range + text: 'Integer values MUST be within the safe range for integers represented using IEEE754 double-precision floating point numbers (−2^53+1 to 2^53−1).' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers - check: sep-2243-client-reject-invalid-tool text: 'Clients MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the set of tools returned by tools/list.' url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers