fix: use negotiated protocol version in sse-multiple-streams raw POSTs (#415)
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 Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a989655390
commit
19a97f0465
2 changed files with 187 additions and 2 deletions
183
src/scenarios/server/sse-multiple-streams.test.ts
Normal file
183
src/scenarios/server/sse-multiple-streams.test.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
async function startVersionEnforcingServer(): Promise<VersionEnforcingServer> {
|
||||
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<void>((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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
Loading…
Reference in a new issue