fix(server): bound resumability version gates to supported versions, pin the unsupported-version rejection format (#2280)

This commit is contained in:
Felix Weinberger 2026-06-12 15:40:42 +01:00 • committed by GitHub
commit e8c7180109
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 356 additions and 7 deletions

View file

@ -367,10 +367,27 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
}
/**
* Returns true if the client's protocol version supports empty SSE data in
* priming events (the fix shipped with protocol version `2025-11-25`).
*
* The version is checked for membership in this transport instance's
* supported protocol versions rather than with an open-ended
* `>= '2025-11-25'` comparison: the value may come from an `initialize`
* request body, which (unlike the `MCP-Protocol-Version` header) is not
* validated against `supportedProtocolVersions` before reaching this
* check. An unknown future version string must not silently enable
* behavior reserved for versions this transport actually supports.
*/
private supportsEmptySSEData(protocolVersion: string): boolean {
return this._supportedProtocolVersions.includes(protocolVersion) && protocolVersion >= '2025-11-25';
}
/**
* Writes a priming event to establish resumption capability.
* Only sends if `eventStore` is configured (opt-in for resumability) and
* the client's protocol version supports empty SSE data (>= `2025-11-25`).
* the client's protocol version supports empty SSE data (a supported
* version that is >= `2025-11-25`).
*/
private async writePrimingEvent(
controller: ReadableStreamDefaultController<Uint8Array>,
@ -383,9 +400,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
// Priming events have empty data which older clients cannot handle.
// Only send priming events to clients with protocol version >= 2025-11-25
// which includes the fix for handling empty SSE data.
if (protocolVersion < '2025-11-25') {
// Only send priming events to clients whose protocol version includes
// the fix for handling empty SSE data.
if (!this.supportsEmptySSEData(protocolVersion)) {
return;
}
@ -795,12 +812,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
// handle each message
for (const message of messages) {
// Build closeSSEStream callback for requests when eventStore is configured
// AND client supports resumability (protocol version >= 2025-11-25).
// AND client supports resumability (a supported protocol version >= 2025-11-25).
// Old clients can't resume if the stream is closed early because they
// didn't receive a priming event with an event ID.
let closeSSEStream: (() => void) | undefined;
let closeStandaloneSSEStream: (() => void) | undefined;
if (isJSONRPCRequest(message) && this._eventStore && clientProtocolVersion >= '2025-11-25') {
if (isJSONRPCRequest(message) && this._eventStore && this.supportsEmptySSEData(clientProtocolVersion)) {
closeSSEStream = () => {
this.closeSSEStream(message.id);
};

View file

@ -0,0 +1,166 @@
import { randomUUID } from 'node:crypto';
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/core';
import { McpServer } from '../../src/server/mcp.js';
import type { EventId, EventStore, StreamId } from '../../src/server/streamableHttp.js';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js';
/**
* Gate-closure tests for the two protocol-version checks that guard
* resumability behavior (priming events and `closeSSEStream` callbacks).
*
* The protocol version in an `initialize` request body is NOT validated
* against `supportedProtocolVersions` (unlike the `MCP-Protocol-Version`
* header), so these gates must be bounded: only versions the transport
* instance actually supports may enable the resumability behavior introduced
* with protocol version 2025-11-25. An unknown future version string must
* behave like a client that does not support it.
*/
function initializeRequest(protocolVersion: string): Request {
const body = {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion,
capabilities: {}
},
id: 'init-1'
} as JSONRPCMessage;
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify(body)
});
}
async function readFirstSSEChunk(response: Response): Promise<string> {
const reader = response.body?.getReader();
const { value } = await reader!.read();
return new TextDecoder().decode(value);
}
/**
* A priming event is an SSE event with an event ID and empty data,
* e.g. `id: <eventId>\ndata: \n\n`.
*/
function isPrimingEvent(sseChunk: string): boolean {
const lines = sseChunk.split('\n');
const dataLine = lines.find(line => line.startsWith('data:'));
return lines.some(line => line.startsWith('id:')) && dataLine !== undefined && dataLine.slice(5).trim() === '';
}
describe('WebStandardStreamableHTTPServerTransport - future-version gate closure', () => {
let transport: WebStandardStreamableHTTPServerTransport;
let mcpServer: McpServer;
let storedEvents: Map<EventId, { streamId: StreamId; message: JSONRPCMessage }>;
let capturedExtras: Array<MessageExtraInfo | undefined>;
beforeEach(async () => {
storedEvents = new Map();
capturedExtras = [];
const eventStore: EventStore = {
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
const eventId = `${streamId}_${storedEvents.size}`;
storedEvents.set(eventId, { streamId, message });
return eventId;
},
async getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined> {
return storedEvents.get(eventId)?.streamId;
},
async replayEventsAfter(): Promise<StreamId> {
throw new Error('not used in these tests');
}
};
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore
});
await mcpServer.connect(transport);
// Capture the per-message extras (closeSSEStream availability) while
// preserving normal server dispatch.
const serverOnMessage = transport.onmessage;
transport.onmessage = (message, extra) => {
capturedExtras.push(extra);
serverOnMessage?.(message, extra);
};
});
afterEach(async () => {
await transport.close();
});
function expectPrimingEventStored(expected: boolean): void {
// The priming event is stored as an empty message; real messages always
// have at least a `jsonrpc` member.
const primingStored = [...storedEvents.values()].some(event => Object.keys(event.message).length === 0);
expect(primingStored).toBe(expected);
}
describe('unknown future protocol versions in the initialize body', () => {
// Far-future sentinels, deliberately not the next planned revision
// (2026-07-28): these cases must stay "unknown" when real future
// versions gain support, rather than silently inverting.
it.each(['2099-01-01', '2099-12-31'])('does not send a priming event for protocol version %s', async futureVersion => {
const response = await transport.handleRequest(initializeRequest(futureVersion));
expect(response.status).toBe(200);
const firstChunk = await readFirstSSEChunk(response);
// The first SSE event must be the initialize response, not a priming event.
expect(isPrimingEvent(firstChunk)).toBe(false);
expect(firstChunk).toContain('"result"');
expectPrimingEventStored(false);
});
it.each(['2099-01-01', '2099-12-31'])('does not provide closeSSEStream callbacks for protocol version %s', async futureVersion => {
const response = await transport.handleRequest(initializeRequest(futureVersion));
expect(response.status).toBe(200);
expect(capturedExtras).toHaveLength(1);
expect(capturedExtras[0]?.closeSSEStream).toBeUndefined();
expect(capturedExtras[0]?.closeStandaloneSSEStream).toBeUndefined();
});
});
describe('existing protocol versions keep their behavior', () => {
// Only 2025-11-25 (the version that introduced the empty-SSE-data fix)
// takes the resumability paths - exactly as before the gates were bounded.
const expectations: Array<[string, boolean]> = [
['2024-10-07', false],
['2024-11-05', false],
['2025-03-26', false],
['2025-06-18', false],
['2025-11-25', true]
];
it.each(expectations)('priming event for protocol version %s: %s', async (version, expectPriming) => {
const response = await transport.handleRequest(initializeRequest(version));
expect(response.status).toBe(200);
const firstChunk = await readFirstSSEChunk(response);
expect(isPrimingEvent(firstChunk)).toBe(expectPriming);
expectPrimingEventStored(expectPriming);
});
it.each(expectations)('closeSSEStream callbacks for protocol version %s: %s', async (version, expectAvailable) => {
const response = await transport.handleRequest(initializeRequest(version));
expect(response.status).toBe(200);
expect(capturedExtras).toHaveLength(1);
expect(capturedExtras[0]?.closeSSEStream !== undefined).toBe(expectAvailable);
expect(capturedExtras[0]?.closeStandaloneSSEStream !== undefined).toBe(expectAvailable);
});
});
});

View file

@ -0,0 +1,139 @@
import { randomUUID } from 'node:crypto';
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core';
import { McpServer } from '../../src/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js';
/**
* Wire-level continuity tests for the "Unsupported protocol version" rejection.
*
* The load-bearing surface is the HTTP 400 status plus the literal substring
* `Unsupported protocol version` in the response body: the go-sdk client
* substring-matches exactly that phrase on non-2xx bodies to drive its
* protocol-version fallback (`streamableClientConn.checkResponse` in
* go-sdk's `mcp/streamable.go`). Its structured JSON-RPC parse path rejects
* this server's `id: null` error body, so the substring is the operative
* interop signal — it must keep appearing verbatim in the wire bytes across
* refactors of the transport internals.
*
* The rest of the message (prefix, echoed version, supported-versions list)
* is asserted against a string derived from `SUPPORTED_PROTOCOL_VERSIONS`
* rather than a frozen byte literal, so these tests survive additions to the
* supported-versions list while still catching any rewording.
*/
const INITIALIZE_MESSAGE = {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-11-25',
capabilities: {}
},
id: 'init-1'
} as JSONRPCMessage;
const TOOLS_LIST_MESSAGE = {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 'tools-1'
} as JSONRPCMessage;
interface JSONRPCErrorBody {
jsonrpc: string;
id: unknown;
error: { code: number; message: string };
}
function postRequest(body: JSONRPCMessage, headers: Record<string, string> = {}): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...headers
},
body: JSON.stringify(body)
});
}
async function initializeServer(transport: WebStandardStreamableHTTPServerTransport): Promise<string> {
const response = await transport.handleRequest(postRequest(INITIALIZE_MESSAGE));
expect(response.status).toBe(200);
return response.headers.get('mcp-session-id') as string;
}
async function connectedTransport(supportedProtocolVersions?: string[]): Promise<WebStandardStreamableHTTPServerTransport> {
// `connect()` passes the server's supported protocol versions down to the
// transport, so a custom list is configured on the server options.
const mcpServer = new McpServer(
{ name: 'test-server', version: '1.0.0' },
{ capabilities: {}, ...(supportedProtocolVersions ? { supportedProtocolVersions } : {}) }
);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await mcpServer.connect(transport);
return transport;
}
describe('Unsupported protocol version - wire literal continuity', () => {
it('rejects an unsupported MCP-Protocol-Version header with HTTP 400, code -32000, and the sniffed literal substring', async () => {
const transport = await connectedTransport();
try {
const sessionId = await initializeServer(transport);
const response = await transport.handleRequest(
postRequest(TOOLS_LIST_MESSAGE, {
'mcp-session-id': sessionId,
'mcp-protocol-version': '2099-01-01'
})
);
expect(response.status).toBe(400);
expect(response.headers.get('content-type')).toContain('application/json');
const rawBody = await response.text();
// The substring deployed clients (go-sdk) sniff must appear
// verbatim in the wire bytes.
expect(rawBody).toContain('Unsupported protocol version');
const body = JSON.parse(rawBody) as JSONRPCErrorBody;
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBeNull();
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toBe(
`Bad Request: Unsupported protocol version: 2099-01-01 (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})`
);
} finally {
await transport.close();
}
});
it('derives the supported-versions suffix from the per-instance supportedProtocolVersions', async () => {
const transport = await connectedTransport(['2025-11-25', '2025-06-18']);
try {
const sessionId = await initializeServer(transport);
const response = await transport.handleRequest(
postRequest(TOOLS_LIST_MESSAGE, {
'mcp-session-id': sessionId,
'mcp-protocol-version': '1999-01-01'
})
);
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toBe(
'Bad Request: Unsupported protocol version: 1999-01-01 (supported versions: 2025-11-25, 2025-06-18)'
);
} finally {
await transport.close();
}
});
});