From 0c21832b0e5c931c3c65d14023fdd4a96ed03bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:53:05 +0000 Subject: [PATCH] fix: use negotiated protocol version in sse-multiple-streams raw POSTs The stateful server-sse-multiple-streams scenario hard-coded mcp-protocol-version: 2025-03-26 on its three raw concurrent POSTs instead of the version negotiated during initialize. A server that enforces the negotiated session version correctly returns 400 to all three POSTs, so the scenario false-failed before exercising the multi-stream behavior under test. Read transport.protocolVersion after client.connect() and use it for the raw POST headers, falling back to the run's spec version if the transport does not expose one. Add a regression test driving the scenario against a fixture server that 400s any request whose MCP-Protocol-Version header does not match what it negotiated. Fixes #412 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz --- .../server/sse-multiple-streams.test.ts | 183 ++++++++++++++++++ src/scenarios/server/sse-multiple-streams.ts | 6 +- 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/scenarios/server/sse-multiple-streams.test.ts diff --git a/src/scenarios/server/sse-multiple-streams.test.ts b/src/scenarios/server/sse-multiple-streams.test.ts new file mode 100644 index 00000000..967f5d8a --- /dev/null +++ b/src/scenarios/server/sse-multiple-streams.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect } from 'vitest'; +import http from 'http'; +import { testContext } from '../../connection/testing'; +import { ServerSSEMultipleStreamsScenario } from './sse-multiple-streams'; +import type { ConformanceCheck } from '../../types'; + +/** + * Regression coverage for issue #412: the stateful scenario must send the + * protocol version negotiated during initialize on its raw follow-up POSTs, + * not a hard-coded historical revision. The fixture server below stores the + * version it returned from initialize and rejects (400) any later request + * whose MCP-Protocol-Version header does not match it. + */ + +const SESSION_ID = 'test-session-412'; + +interface VersionEnforcingServer { + url: string; + negotiatedVersion: () => string | undefined; + close: () => Promise; +} + +async function startVersionEnforcingServer(): Promise { + let negotiated: string | undefined; + + const server = http.createServer((req, res) => { + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let body: { + id?: number | string; + method?: string; + params?: { protocolVersion?: string }; + } = {}; + try { + body = JSON.parse(raw); + } catch { + // Treat unparseable bodies as empty requests. + } + const respond = (status: number, payload?: object) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(payload ? JSON.stringify(payload) : undefined); + }; + + if (body.method === 'initialize') { + negotiated = body.params?.protocolVersion; + res.setHeader('mcp-session-id', SESSION_ID); + respond(200, { + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: negotiated, + capabilities: {}, + serverInfo: { name: 'version-enforcing-server', version: '1.0.0' } + } + }); + return; + } + + if (req.headers['mcp-protocol-version'] !== negotiated) { + respond(400, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32000, message: 'Protocol version mismatch' } + }); + return; + } + + if (body.method === 'notifications/initialized') { + respond(202); + return; + } + if (body.method === 'tools/list') { + respond(200, { jsonrpc: '2.0', id: body.id, result: { tools: [] } }); + return; + } + respond(404, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32601, message: 'Not found' } + }); + }); + }); + + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://localhost:${port}/mcp`, + negotiatedVersion: () => negotiated, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + }; +} + +const findAll = (checks: ConformanceCheck[], id: string) => + checks.filter((c) => c.id === id); + +describe('server-sse-multiple-streams — negotiated protocol version', () => { + test('passes against a server that rejects non-negotiated versions', async () => { + const srv = await startVersionEnforcingServer(); + + const scenario = new ServerSSEMultipleStreamsScenario(); + let checks: ConformanceCheck[]; + try { + checks = await scenario.run(testContext(srv.url)); + } finally { + await srv.close(); + } + + // The scenario negotiated a current version, not the old hard-coded pin. + expect(srv.negotiatedVersion()).toBeDefined(); + expect(srv.negotiatedVersion()).not.toBe('2025-03-26'); + + const accepted = findAll(checks, 'server-accepts-multiple-post-streams')[0]; + expect(accepted?.status).toBe('SUCCESS'); + expect(accepted?.details).toMatchObject({ + numStreamsAttempted: 3, + numStreamsAccepted: 3 + }); + expect(checks.every((c) => c.status !== 'FAILURE')).toBe(true); + }); + + test('fixture server rejects a supported but non-negotiated version', async () => { + const srv = await startVersionEnforcingServer(); + + try { + const init = await fetch(srv.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' } + } + }) + }); + expect(init.status).toBe(200); + + const listWith = (version: string) => + fetch(srv.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': SESSION_ID, + 'mcp-protocol-version': version + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {} + }) + }); + + // The old hard-coded pin is a valid MCP version but not the one + // negotiated above, so this fixture must reject it. + const rejected = await listWith('2025-03-26'); + expect(rejected.status).toBe(400); + + const acceptedRes = await listWith('2025-11-25'); + expect(acceptedRes.status).toBe(200); + } finally { + await srv.close(); + } + }); +}); diff --git a/src/scenarios/server/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index 23f7e797..d2eb4931 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -37,6 +37,7 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { const stateless = specVersion === DRAFT_PROTOCOL_VERSION; let sessionId: string | undefined; + let negotiatedProtocolVersion: string | undefined; let client: Client | undefined; let transport: StreamableHTTPClientTransport | undefined; @@ -59,8 +60,9 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { transport = new StreamableHTTPClientTransport(new URL(serverUrl)); await client.connect(transport); - // Extract session ID from transport + // Extract session ID and negotiated protocol version from transport sessionId = (transport as unknown as { sessionId?: string }).sessionId; + negotiatedProtocolVersion = transport.protocolVersion; if (!sessionId) { checks.push({ @@ -96,7 +98,7 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json', 'mcp-session-id': sessionId!, - 'mcp-protocol-version': '2025-03-26' + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion }; const requestParams = stateless ? {