From 878c03e41d7bc003fe81f7faab08dccc3fe6bd20 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 23 Apr 2026 00:06:28 +0100 Subject: [PATCH] feat: add conformance scenario for MCP-Protocol-Version header 400 response Tests the spec MUST (basic/transports#protocol-version-header) that servers respond with HTTP 400 to requests carrying an invalid or unsupported MCP-Protocol-Version header. The scenario sends tools/list with three bad header values: - malformed: "invalid-protocol-version" - unsupported past date: "2000-01-01" - unsupported future date: "2099-01-01" Each is tested both before initialize (no session) and after a full initialize handshake (with valid Mcp-Session-Id), so header validation is isolated from session-ID validation on stateful servers. Six checks total. --- src/scenarios/index.ts | 2 + .../server/protocol-version-header.test.ts | 231 ++++++++++++++++++ .../server/protocol-version-header.ts | 228 +++++++++++++++++ 3 files changed, 461 insertions(+) create mode 100644 src/scenarios/server/protocol-version-header.test.ts create mode 100644 src/scenarios/server/protocol-version-header.ts diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 665a7a9..354ee46 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -11,6 +11,7 @@ import { SSERetryScenario } from './client/sse-retry'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; +import { ServerProtocolVersionHeaderScenario } from './server/protocol-version-header'; import { PingScenario, @@ -83,6 +84,7 @@ const pendingClientScenariosList: ClientScenario[] = [ const allClientScenariosList: ClientScenario[] = [ // Lifecycle scenarios new ServerInitializeScenario(), + new ServerProtocolVersionHeaderScenario(), // Utilities scenarios new LoggingSetLevelScenario(), diff --git a/src/scenarios/server/protocol-version-header.test.ts b/src/scenarios/server/protocol-version-header.test.ts new file mode 100644 index 0000000..8dcae07 --- /dev/null +++ b/src/scenarios/server/protocol-version-header.test.ts @@ -0,0 +1,231 @@ +import { ServerProtocolVersionHeaderScenario } from './protocol-version-header'; + +describe('ServerProtocolVersionHeaderScenario', () => { + const serverUrl = 'http://localhost:3000/mcp'; + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockHappyPath(sessionId = 'session-abc') { + fetchMock.mockImplementation((_url, init) => { + const body = JSON.parse(init.body); + if (body.method === 'initialize') { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'mcp-session-id': sessionId } + }) + ); + } + if (body.method === 'notifications/initialized') { + return Promise.resolve(new Response(null, { status: 202 })); + } + return Promise.resolve(new Response('bad version', { status: 400 })); + }); + } + + it('emits 6 checks (3 header values × pre/post-init) all SUCCESS when server responds 400', async () => { + mockHappyPath(); + + const checks = await new ServerProtocolVersionHeaderScenario().run( + serverUrl + ); + + expect(checks.map((c) => c.id)).toEqual([ + 'server-protocol-version-header-malformed', + 'server-protocol-version-header-unsupported-past', + 'server-protocol-version-header-unsupported-future', + 'server-protocol-version-header-malformed-post-init', + 'server-protocol-version-header-unsupported-past-post-init', + 'server-protocol-version-header-unsupported-future-post-init' + ]); + for (const check of checks) { + expect(check.status).toBe('SUCCESS'); + expect(check.specReferences?.[0]?.id).toBe('MCP-Protocol-Version-Header'); + } + }); + + it('sends tools/list with each bad header value, and includes session-id post-init', async () => { + mockHappyPath('session-abc'); + + await new ServerProtocolVersionHeaderScenario().run(serverUrl); + + const toolsListCalls = fetchMock.mock.calls.filter( + ([, init]) => JSON.parse(init.body).method === 'tools/list' + ); + expect(toolsListCalls).toHaveLength(6); + + const headerValues = toolsListCalls.map( + ([, init]) => + (init.headers as Record)['MCP-Protocol-Version'] + ); + expect(headerValues).toEqual([ + 'invalid-protocol-version', + '2000-01-01', + '2099-01-01', + 'invalid-protocol-version', + '2000-01-01', + '2099-01-01' + ]); + + // pre-init: no session-id header + for (const [, init] of toolsListCalls.slice(0, 3)) { + expect( + (init.headers as Record)['Mcp-Session-Id'] + ).toBeUndefined(); + } + // post-init: session-id header present + for (const [, init] of toolsListCalls.slice(3)) { + expect((init.headers as Record)['Mcp-Session-Id']).toBe( + 'session-abc' + ); + } + }); + + it('completes the initialize handshake with valid headers before post-init checks', async () => { + mockHappyPath(); + + await new ServerProtocolVersionHeaderScenario().run(serverUrl); + + const initCall = fetchMock.mock.calls.find( + ([, init]) => JSON.parse(init.body).method === 'initialize' + ); + expect(initCall).toBeDefined(); + expect( + (initCall![1].headers as Record)['MCP-Protocol-Version'] + ).toBe('2025-11-25'); + + const initializedCall = fetchMock.mock.calls.find( + ([, init]) => JSON.parse(init.body).method === 'notifications/initialized' + ); + expect(initializedCall).toBeDefined(); + }); + + it('returns FAILURE when the server responds with a non-400 status', async () => { + fetchMock.mockImplementation((_url, init) => { + const body = JSON.parse(init.body); + if (body.method === 'initialize') { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'mcp-session-id': 's' } + }) + ); + } + return Promise.resolve(new Response('{}', { status: 200 })); + }); + + const checks = await new ServerProtocolVersionHeaderScenario().run( + serverUrl + ); + + const toolsChecks = checks.filter((c) => c.id.includes('protocol-version')); + expect(toolsChecks).toHaveLength(6); + for (const check of toolsChecks) { + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('got 200'); + } + }); + + it('emits FAILURE for post-init checks when initialize itself fails', async () => { + fetchMock.mockImplementation((_url, init) => { + const body = JSON.parse(init.body); + if (body.method === 'initialize') { + return Promise.resolve(new Response('nope', { status: 500 })); + } + return Promise.resolve(new Response(null, { status: 400 })); + }); + + const checks = await new ServerProtocolVersionHeaderScenario().run( + serverUrl + ); + + expect(checks).toHaveLength(6); + expect(checks.slice(0, 3).map((c) => c.status)).toEqual([ + 'SUCCESS', + 'SUCCESS', + 'SUCCESS' + ]); + for (const check of checks.slice(3)) { + expect(check.id).toMatch(/-post-init$/); + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Failed to initialize session'); + } + }); + + it('omits session-id header on post-init requests when server is stateless (no session-id returned)', async () => { + fetchMock.mockImplementation((_url, init) => { + const body = JSON.parse(init.body); + if (body.method === 'initialize') { + return Promise.resolve(new Response('{}', { status: 200 })); + } + return Promise.resolve(new Response(null, { status: 400 })); + }); + + const checks = await new ServerProtocolVersionHeaderScenario().run( + serverUrl + ); + + expect(checks).toHaveLength(6); + for (const check of checks) { + expect(check.status).toBe('SUCCESS'); + } + + const postInitCalls = fetchMock.mock.calls + .filter(([, init]) => JSON.parse(init.body).method === 'tools/list') + .slice(3); + for (const [, init] of postInitCalls) { + expect( + (init.headers as Record)['Mcp-Session-Id'] + ).toBeUndefined(); + } + }); + + it('reports independent results when only some header values are rejected', async () => { + fetchMock.mockImplementation((_url, init) => { + const body = JSON.parse(init.body); + if (body.method === 'initialize') { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'mcp-session-id': 's' } + }) + ); + } + if (body.method === 'notifications/initialized') { + return Promise.resolve(new Response(null, { status: 202 })); + } + const version = (init.headers as Record)[ + 'MCP-Protocol-Version' + ]; + // Simulate a server that only rejects malformed versions, not unsupported dates + const status = version === 'invalid-protocol-version' ? 400 : 200; + return Promise.resolve(new Response(null, { status })); + }); + + const checks = await new ServerProtocolVersionHeaderScenario().run( + serverUrl + ); + + expect(checks[0]).toMatchObject({ + id: 'server-protocol-version-header-malformed', + status: 'SUCCESS' + }); + expect(checks[1]).toMatchObject({ + id: 'server-protocol-version-header-unsupported-past', + status: 'FAILURE', + details: { sentHeader: '2000-01-01', statusCode: 200 } + }); + expect(checks[2]?.status).toBe('FAILURE'); + expect(checks[3]?.status).toBe('SUCCESS'); + expect(checks[4]?.status).toBe('FAILURE'); + expect(checks[5]?.status).toBe('FAILURE'); + }); +}); diff --git a/src/scenarios/server/protocol-version-header.ts b/src/scenarios/server/protocol-version-header.ts new file mode 100644 index 0000000..e2d11b3 --- /dev/null +++ b/src/scenarios/server/protocol-version-header.ts @@ -0,0 +1,228 @@ +/** + * MCP-Protocol-Version header validation scenario for MCP servers (HTTP transport). + * + * Spec (2025-06-18 / 2025-11-25, basic/transports#protocol-version-header): + * "If the server receives a request with an invalid or unsupported + * MCP-Protocol-Version, it MUST respond with 400 Bad Request." + */ + +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; + +const VALID_PROTOCOL_VERSION = '2025-11-25'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-Protocol-Version-Header', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header' + } +]; + +interface HeaderCase { + id: string; + name: string; + headerValue: string; + reason: string; +} + +const CASES: HeaderCase[] = [ + { + id: 'server-protocol-version-header-malformed', + name: 'ServerProtocolVersionHeaderMalformed', + headerValue: 'invalid-protocol-version', + reason: 'malformed (not a date string)' + }, + { + id: 'server-protocol-version-header-unsupported-past', + name: 'ServerProtocolVersionHeaderUnsupportedPast', + headerValue: '2000-01-01', + reason: + 'well-formed but unsupported (lexicographically before any real version)' + }, + { + id: 'server-protocol-version-header-unsupported-future', + name: 'ServerProtocolVersionHeaderUnsupportedFuture', + headerValue: '2099-01-01', + reason: + 'well-formed but unsupported (lexicographically after any real version)' + } +]; + +export class ServerProtocolVersionHeaderScenario implements ClientScenario { + name = 'server-protocol-version-header'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + description = `Test that the server rejects invalid or unsupported \`MCP-Protocol-Version\` header values with HTTP 400. + +**Spec requirement** (basic/transports#protocol-version-header): +> If the server receives a request with an invalid or unsupported \`MCP-Protocol-Version\`, it **MUST** respond with \`400 Bad Request\`. + +This scenario sends a \`tools/list\` request (without prior \`initialize\`) using three bad header values: +- a malformed string (\`invalid-protocol-version\`) +- a well-formed but unsupported past date (\`2000-01-01\`) +- a well-formed but unsupported future date (\`2099-01-01\`) + +The past/future pair catches servers that validate via string comparison against a single bound rather than an explicit allowlist of supported versions. + +Each header value is tested twice: once on a fresh connection without a session (pre-init), and once after a successful \`initialize\` handshake with a valid \`Mcp-Session-Id\` (post-init). The post-init checks isolate header validation from session-ID validation on servers that require sessions.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + for (const c of CASES) { + checks.push(await this.runCase(serverUrl, c, 'pre-init')); + } + + let sessionId: string | null; + try { + sessionId = await this.initialize(serverUrl); + } catch (error) { + const errorMessage = `Failed to initialize session for post-init checks: ${error instanceof Error ? error.message : String(error)}`; + for (const c of CASES) { + checks.push({ + id: `${c.id}-post-init`, + name: `${c.name}PostInit`, + description: `Server responds 400 to MCP-Protocol-Version header that is ${c.reason} (after initialize)`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage, + specReferences: SPEC_REFERENCES + }); + } + return checks; + } + + for (const c of CASES) { + checks.push(await this.runCase(serverUrl, c, 'post-init', sessionId)); + } + + return checks; + } + + private async initialize(serverUrl: string): Promise { + const initResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': VALID_PROTOCOL_VERSION + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: VALID_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'conformance-protocol-version-header-test', + version: '1.0.0' + } + } + }) + }); + + if (!initResponse.ok) { + throw new Error( + `initialize returned ${initResponse.status} ${initResponse.statusText}` + ); + } + + const sessionId = initResponse.headers.get('mcp-session-id'); + + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': VALID_PROTOCOL_VERSION, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}) + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }) + }); + + return sessionId; + } + + private async runCase( + serverUrl: string, + c: HeaderCase, + phase: 'pre-init' | 'post-init', + sessionId?: string | null + ): Promise { + const idSuffix = phase === 'post-init' ? '-post-init' : ''; + const nameSuffix = phase === 'post-init' ? 'PostInit' : ''; + const phaseLabel = + phase === 'post-init' ? ' (after initialize)' : ' (before initialize)'; + const description = `Server responds 400 to MCP-Protocol-Version header that is ${c.reason}${phaseLabel}`; + const timestamp = new Date().toISOString(); + + try { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': c.headerValue, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}) + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }) + }); + + let body: unknown = null; + try { + body = await response.text(); + } catch { + // ignore body read errors + } + + const details = { + phase, + sentHeader: c.headerValue, + sessionId: sessionId ?? null, + statusCode: response.status, + body + }; + + if (response.status === 400) { + return { + id: `${c.id}${idSuffix}`, + name: `${c.name}${nameSuffix}`, + description, + status: 'SUCCESS', + timestamp, + specReferences: SPEC_REFERENCES, + details + }; + } + + return { + id: `${c.id}${idSuffix}`, + name: `${c.name}${nameSuffix}`, + description, + status: 'FAILURE', + timestamp, + errorMessage: `Expected HTTP 400 for MCP-Protocol-Version "${c.headerValue}", got ${response.status}`, + specReferences: SPEC_REFERENCES, + details + }; + } catch (error) { + return { + id: `${c.id}${idSuffix}`, + name: `${c.name}${nameSuffix}`, + description, + status: 'FAILURE', + timestamp, + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFERENCES, + details: { phase, sentHeader: c.headerValue } + }; + } + } +}