diff --git a/src/scenarios/server/sse-polling.test.ts b/src/scenarios/server/sse-polling.test.ts new file mode 100644 index 00000000..e09cd9a1 --- /dev/null +++ b/src/scenarios/server/sse-polling.test.ts @@ -0,0 +1,235 @@ +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 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; + seenRequests: () => SeenRequest[]; + close: () => Promise; +} + +// Down-negotiates every initialize to DOWN_NEGOTIATED_VERSION so a scenario +// 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: SeenRequest[] = []; + + const server = http.createServer((req, res) => { + 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, + hasLastEventId: Boolean(req.headers['last-event-id']) + }); + if (version !== negotiated || !sessionOk) { + reject(mismatchError); + return; + } + if (!req.headers['last-event-id']) { + // 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); + 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 || !sessionOk) { + reject({ ...mismatchError, id: body.id ?? null }); + return; + } + + if (body.method === 'notifications/initialized') { + respond(202); + return; + } + if (body.method === 'tools/call') { + // 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'); + 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`, + seenRequests: () => 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(); + } + + // 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( + [...toolCalls, ...reconnectGets].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..a15d5e98 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) - sessionId = (transport as unknown as { sessionId?: string }).sessionId; + // Extract session ID and negotiated protocol version from transport + sessionId = transport.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 } });