fix: use negotiated protocol version in server-sse-polling raw requests (#416)
* fix: use negotiated protocol version in sse-polling raw requests 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
31119a05f0
commit
0c85302241
2 changed files with 242 additions and 5 deletions
235
src/scenarios/server/sse-polling.test.ts
Normal file
235
src/scenarios/server/sse-polling.test.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
// 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<VersionEnforcingServer> {
|
||||
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<void>((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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ConformanceCheck[]> {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue