From 6913a1f419a8c7cb710b9e6c04de57869d3aefdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:09:19 +0000 Subject: [PATCH 1/2] fix: use negotiated protocol version in sse-polling raw requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-sse-polling scenario hard-coded mcp-protocol-version: 2025-11-25 on its raw tools/call POST and Last-Event-ID reconnection GET instead of the version negotiated during initialize — the same bug class as #412. A server that enforces the negotiated session version rejects those requests once it negotiates any other version. Read transport.protocolVersion after client.connect() and use it for both raw request headers, falling back to the run's spec version, mirroring the sse-multiple-streams fix (#415). Add a regression test whose fixture server down-negotiates to 2025-06-18 and 400s any request whose MCP-Protocol-Version header does not match, so a fix that hard-codes the latest version cannot pass. Follow-up to #415. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz --- src/scenarios/server/sse-polling.test.ts | 211 +++++++++++++++++++++++ src/scenarios/server/sse-polling.ts | 10 +- 2 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/server/sse-polling.test.ts diff --git a/src/scenarios/server/sse-polling.test.ts b/src/scenarios/server/sse-polling.test.ts new file mode 100644 index 00000000..7a9901fd --- /dev/null +++ b/src/scenarios/server/sse-polling.test.ts @@ -0,0 +1,211 @@ +import { describe, test, expect } from 'vitest'; +import http from 'http'; +import { testContext } from '../../connection/testing'; +import { ServerSSEPollingScenario } from './sse-polling'; +import { LATEST_SPEC_VERSION, type ConformanceCheck } from '../../types'; + +/** + * Regression coverage for the #412 bug class in sse-polling: raw follow-up + * requests must carry the initialize-negotiated protocol version, enforced + * here by a fixture that down-negotiates and 400s mismatched requests. + */ + +const SESSION_ID = 'test-session-sse-polling'; +const DOWN_NEGOTIATED_VERSION = '2025-06-18'; + +interface VersionEnforcingServer { + url: string; + seenVersions: () => Array<{ request: string; version: string | undefined }>; + close: () => Promise; +} + +// Down-negotiates every initialize to DOWN_NEGOTIATED_VERSION so a scenario +// that hard-codes the latest version cannot pass, records the version header +// of each follow-up request, and 400s any that mismatch. +async function startVersionEnforcingServer(): Promise { + let negotiated: string | undefined; + const seen: Array<{ request: string; version: string | undefined }> = []; + + const server = http.createServer((req, res) => { + const version = req.headers['mcp-protocol-version'] as string | undefined; + const sseHead = { 'Content-Type': 'text/event-stream' }; + + if (req.method === 'GET') { + seen.push({ request: 'GET', version }); + if (version !== negotiated) { + res.writeHead(400).end(); + return; + } + if (!req.headers['last-event-id']) { + res.writeHead(405).end(); + return; + } + res.writeHead(200, sseHead); + res.end( + 'id: evt-2\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[]}}\n\n' + ); + 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 = DOWN_NEGOTIATED_VERSION; + 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; + } + + seen.push({ request: body.method ?? 'unknown', version }); + if (version !== negotiated) { + respond(400, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { + code: -32000, + message: `Protocol version mismatch: got ${version}, negotiated ${negotiated}` + } + }); + return; + } + + if (body.method === 'notifications/initialized') { + respond(202); + return; + } + if (body.method === 'tools/call') { + // Priming event with id, then close without the result, so the + // scenario must reconnect via GET + Last-Event-ID (line-420 path). + res.writeHead(200, sseHead); + res.end('id: evt-1\ndata: {}\n\n'); + return; + } + respond(404, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32601, message: 'Not found' } + }); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}/mcp`, + seenVersions: () => seen, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + }; +} + +const findAll = (checks: ConformanceCheck[], id: string) => + checks.filter((c) => c.id === id); + +describe('server-sse-polling — negotiated protocol version', () => { + test('raw POST and GET carry the down-negotiated version', async () => { + const srv = await startVersionEnforcingServer(); + + const scenario = new ServerSSEPollingScenario(); + let checks: ConformanceCheck[]; + try { + checks = await scenario.run(testContext(srv.url, LATEST_SPEC_VERSION)); + } finally { + await srv.close(); + } + + // Every raw follow-up (tools/call POST, reconnect GET) must have sent + // the negotiated version, not the latest/spec version. + const followUps = srv + .seenVersions() + .filter((s) => s.request === 'tools/call' || s.request === 'GET'); + expect(followUps.length).toBeGreaterThanOrEqual(2); + expect( + followUps.filter((s) => s.version !== DOWN_NEGOTIATED_VERSION) + ).toEqual([]); + + const resume = findAll(checks, 'server-sse-disconnect-resume')[0]; + expect(resume?.status).toBe('SUCCESS'); + expect(checks.filter((c) => c.status === 'FAILURE')).toEqual([]); + }); + + test('fixture 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: LATEST_SPEC_VERSION, + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' } + } + }) + }); + expect(init.status).toBe(200); + + const callWith = (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/call', + params: { name: 'test_reconnection', arguments: {} } + }) + }); + + // The latest version is valid MCP but was not negotiated above. + const rejected = await callWith(LATEST_SPEC_VERSION); + expect(rejected.status).toBe(400); + + const accepted = await callWith(DOWN_NEGOTIATED_VERSION); + expect(accepted.status).toBe(200); + await accepted.body?.cancel(); + } finally { + await srv.close(); + } + }); +}); diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index d197d3f9..89bbd6eb 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -80,10 +80,11 @@ export class ServerSSEPollingScenario implements ClientScenario { 'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)'; async run(ctx: RunContext): Promise { - const { serverUrl } = ctx; + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; let sessionId: string | undefined; + let negotiatedProtocolVersion: string | undefined; let client: Client | undefined; let transport: StreamableHTTPClientTransport | undefined; @@ -105,8 +106,9 @@ export class ServerSSEPollingScenario implements ClientScenario { transport = new StreamableHTTPClientTransport(new URL(serverUrl)); await client.connect(transport); - // Extract session ID from transport (accessing internal state) + // Extract session ID and negotiated protocol version from transport sessionId = (transport as unknown as { sessionId?: string }).sessionId; + negotiatedProtocolVersion = transport.protocolVersion; if (!sessionId) { checks.push({ @@ -138,7 +140,7 @@ export class ServerSSEPollingScenario implements ClientScenario { 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json', ...(sessionId && { 'mcp-session-id': sessionId }), - 'mcp-protocol-version': '2025-11-25' + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion }, body: JSON.stringify({ jsonrpc: '2.0', @@ -417,7 +419,7 @@ export class ServerSSEPollingScenario implements ClientScenario { headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-11-25', + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion, 'last-event-id': lastEventId } }); From 75702c73980cbafd2216d4c95e535f51a1cb9e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:20:51 +0000 Subject: [PATCH 2/2] test: harden sse-polling regression fixture per review panel Enforce session id alongside protocol version in the fixture, record Last-Event-ID presence per GET so the SDK's automatic standalone GET cannot satisfy the reconnect assertion, normalize the header type instead of casting, return a descriptive 400 body on the GET path, add Allow to 405s, use the spec-shaped empty-data priming event, and read sessionId via the SDK's public getter in the scenario. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz --- src/scenarios/server/sse-polling.test.ts | 92 +++++++++++++++--------- src/scenarios/server/sse-polling.ts | 2 +- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/scenarios/server/sse-polling.test.ts b/src/scenarios/server/sse-polling.test.ts index 7a9901fd..e09cd9a1 100644 --- a/src/scenarios/server/sse-polling.test.ts +++ b/src/scenarios/server/sse-polling.test.ts @@ -4,40 +4,65 @@ import { testContext } from '../../connection/testing'; import { ServerSSEPollingScenario } from './sse-polling'; import { LATEST_SPEC_VERSION, type ConformanceCheck } from '../../types'; -/** - * Regression coverage for the #412 bug class in sse-polling: raw follow-up - * requests must carry the initialize-negotiated protocol version, enforced - * here by a fixture that down-negotiates and 400s mismatched requests. - */ +// Regression coverage for the #412 bug class in sse-polling: raw follow-up +// requests must carry the initialize-negotiated protocol version and session +// id, enforced by a fixture that down-negotiates and 400s mismatched requests. const SESSION_ID = 'test-session-sse-polling'; const DOWN_NEGOTIATED_VERSION = '2025-06-18'; +interface SeenRequest { + request: string; + version: string | undefined; + hasLastEventId?: boolean; +} + interface VersionEnforcingServer { url: string; - seenVersions: () => Array<{ request: string; version: string | undefined }>; + seenRequests: () => SeenRequest[]; close: () => Promise; } // Down-negotiates every initialize to DOWN_NEGOTIATED_VERSION so a scenario -// that hard-codes the latest version cannot pass, records the version header -// of each follow-up request, and 400s any that mismatch. +// that hard-codes the latest version cannot pass, records each follow-up's +// version header, and 400s any version or session mismatch. async function startVersionEnforcingServer(): Promise { let negotiated: string | undefined; - const seen: Array<{ request: string; version: string | undefined }> = []; + const seen: SeenRequest[] = []; const server = http.createServer((req, res) => { - const version = req.headers['mcp-protocol-version'] as string | undefined; + const rawVersion = req.headers['mcp-protocol-version']; + const version = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion; + const sessionOk = req.headers['mcp-session-id'] === SESSION_ID; const sseHead = { 'Content-Type': 'text/event-stream' }; + const reject = (payload: object) => { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + const mismatchError = { + jsonrpc: '2.0', + id: null, + error: { + code: -32000, + message: `Session or protocol version mismatch: got ${version}, negotiated ${negotiated}` + } + }; if (req.method === 'GET') { - seen.push({ request: 'GET', version }); - if (version !== negotiated) { - res.writeHead(400).end(); + seen.push({ + request: 'GET', + version, + hasLastEventId: Boolean(req.headers['last-event-id']) + }); + if (version !== negotiated || !sessionOk) { + reject(mismatchError); return; } if (!req.headers['last-event-id']) { - res.writeHead(405).end(); + // Absorbs the SDK's automatic standalone GET after initialized; + // the SDK tolerates 405, and the scenario's reconnect GET differs + // by carrying Last-Event-ID. + res.writeHead(405, { Allow: 'POST' }).end(); return; } res.writeHead(200, sseHead); @@ -82,15 +107,8 @@ async function startVersionEnforcingServer(): Promise { } seen.push({ request: body.method ?? 'unknown', version }); - if (version !== negotiated) { - respond(400, { - jsonrpc: '2.0', - id: body.id ?? null, - error: { - code: -32000, - message: `Protocol version mismatch: got ${version}, negotiated ${negotiated}` - } - }); + if (version !== negotiated || !sessionOk) { + reject({ ...mismatchError, id: body.id ?? null }); return; } @@ -99,10 +117,10 @@ async function startVersionEnforcingServer(): Promise { return; } if (body.method === 'tools/call') { - // Priming event with id, then close without the result, so the - // scenario must reconnect via GET + Last-Event-ID (line-420 path). + // Spec-shaped priming event (id + empty data), then close without + // the result, so the scenario must reconnect via GET + Last-Event-ID. res.writeHead(200, sseHead); - res.end('id: evt-1\ndata: {}\n\n'); + res.end('id: evt-1\ndata: \n\n'); return; } respond(404, { @@ -118,7 +136,7 @@ async function startVersionEnforcingServer(): Promise { const port = typeof address === 'object' && address ? address.port : 0; return { url: `http://127.0.0.1:${port}/mcp`, - seenVersions: () => seen, + seenRequests: () => seen, close: () => new Promise((resolve) => { server.closeAllConnections?.(); @@ -142,14 +160,20 @@ describe('server-sse-polling — negotiated protocol version', () => { await srv.close(); } - // Every raw follow-up (tools/call POST, reconnect GET) must have sent - // the negotiated version, not the latest/spec version. - const followUps = srv - .seenVersions() - .filter((s) => s.request === 'tools/call' || s.request === 'GET'); - expect(followUps.length).toBeGreaterThanOrEqual(2); + // Both raw sites must have fired and sent the negotiated version, not + // the latest/spec version. + const toolCalls = srv + .seenRequests() + .filter((s) => s.request === 'tools/call'); + const reconnectGets = srv + .seenRequests() + .filter((s) => s.request === 'GET' && s.hasLastEventId); + expect(toolCalls.length).toBeGreaterThanOrEqual(1); + expect(reconnectGets.length).toBeGreaterThanOrEqual(1); expect( - followUps.filter((s) => s.version !== DOWN_NEGOTIATED_VERSION) + [...toolCalls, ...reconnectGets].filter( + (s) => s.version !== DOWN_NEGOTIATED_VERSION + ) ).toEqual([]); const resume = findAll(checks, 'server-sse-disconnect-resume')[0]; diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 89bbd6eb..a15d5e98 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -107,7 +107,7 @@ export class ServerSSEPollingScenario implements ClientScenario { await client.connect(transport); // Extract session ID and negotiated protocol version from transport - sessionId = (transport as unknown as { sessionId?: string }).sessionId; + sessionId = transport.sessionId; negotiatedProtocolVersion = transport.protocolVersion; if (!sessionId) {