Skip to content

Commit e8c7180

Browse files
fix(server): bound resumability version gates to supported versions, pin the unsupported-version rejection format (#2280)
1 parent 278d725 commit e8c7180

6 files changed

Lines changed: 356 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
Bound the protocol-version checks gating SSE resumability behavior (priming events and `closeSSEStream` callbacks) in the Streamable HTTP server transport. Previously an open-ended `>= '2025-11-25'` comparison let unknown future protocol version strings from an `initialize`
6+
request body enable this behavior; the version must now also be one of the transport's supported protocol versions. Behavior for all currently supported protocol versions is unchanged.

docs/migration-SKILL.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,10 +503,19 @@ The 2025-11 task side-channel through `Protocol` is removed (was always `@experi
503503

504504
NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification unions, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`.
505505

506-
## 13. Client Behavioral Changes
506+
## 13. Behavioral Changes
507+
508+
### Client
507509

508510
`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead.
509511

512+
### Server (Streamable HTTP transport)
513+
514+
No code changes required; these are wire-behavior notes:
515+
516+
- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. Behavior for all currently supported protocol versions is unchanged.
517+
- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — migrated client code should key off the HTTP `404` status, not the `-32001` code.
518+
510519
## 14. Runtime-Specific JSON Schema Validators (Enhancement)
511520

512521
The SDK now auto-selects the appropriate JSON Schema validator based on runtime:

docs/migration.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ app.use(hostHeaderValidation(['example.com']));
334334

335335
Note: the v2 signature takes a plain `string[]` instead of an options object.
336336

337+
### Resumability gating for unknown protocol versions (Streamable HTTP server)
338+
339+
The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an
340+
open-ended `protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior.
341+
342+
The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through
343+
`2025-11-25`) is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided.
344+
337345
### `setRequestHandler` and `setNotificationHandler` use method strings
338346

339347
The low-level `setRequestHandler` and `setNotificationHandler` methods on `Client`, `Server`, and `Protocol` now take a method string instead of a Zod schema.
@@ -989,6 +997,10 @@ The following APIs are unchanged between v1 and v2 (only the import paths change
989997
- All Zod schemas and type definitions from `types.ts` (except the aliases listed above)
990998
- Tool, prompt, and resource callback return types
991999

1000+
**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and
1001+
message `Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the
1002+
`-32001` code in client logic; key off the HTTP `404` status instead.
1003+
9921004
## Using an LLM to migrate your code
9931005

9941006
An LLM-optimized version of this guide is available at [`docs/migration-SKILL.md`](migration-SKILL.md). It contains dense mapping tables designed for tools like Claude Code to mechanically apply all the changes described above. You can paste it into your LLM context or load it as

packages/server/src/server/streamableHttp.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -367,10 +367,27 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
367367
}
368368
}
369369

370+
/**
371+
* Returns true if the client's protocol version supports empty SSE data in
372+
* priming events (the fix shipped with protocol version `2025-11-25`).
373+
*
374+
* The version is checked for membership in this transport instance's
375+
* supported protocol versions rather than with an open-ended
376+
* `>= '2025-11-25'` comparison: the value may come from an `initialize`
377+
* request body, which (unlike the `MCP-Protocol-Version` header) is not
378+
* validated against `supportedProtocolVersions` before reaching this
379+
* check. An unknown future version string must not silently enable
380+
* behavior reserved for versions this transport actually supports.
381+
*/
382+
private supportsEmptySSEData(protocolVersion: string): boolean {
383+
return this._supportedProtocolVersions.includes(protocolVersion) && protocolVersion >= '2025-11-25';
384+
}
385+
370386
/**
371387
* Writes a priming event to establish resumption capability.
372388
* Only sends if `eventStore` is configured (opt-in for resumability) and
373-
* the client's protocol version supports empty SSE data (>= `2025-11-25`).
389+
* the client's protocol version supports empty SSE data (a supported
390+
* version that is >= `2025-11-25`).
374391
*/
375392
private async writePrimingEvent(
376393
controller: ReadableStreamDefaultController<Uint8Array>,
@@ -383,9 +400,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
383400
}
384401

385402
// Priming events have empty data which older clients cannot handle.
386-
// Only send priming events to clients with protocol version >= 2025-11-25
387-
// which includes the fix for handling empty SSE data.
388-
if (protocolVersion < '2025-11-25') {
403+
// Only send priming events to clients whose protocol version includes
404+
// the fix for handling empty SSE data.
405+
if (!this.supportsEmptySSEData(protocolVersion)) {
389406
return;
390407
}
391408

@@ -795,12 +812,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
795812
// handle each message
796813
for (const message of messages) {
797814
// Build closeSSEStream callback for requests when eventStore is configured
798-
// AND client supports resumability (protocol version >= 2025-11-25).
815+
// AND client supports resumability (a supported protocol version >= 2025-11-25).
799816
// Old clients can't resume if the stream is closed early because they
800817
// didn't receive a priming event with an event ID.
801818
let closeSSEStream: (() => void) | undefined;
802819
let closeStandaloneSSEStream: (() => void) | undefined;
803-
if (isJSONRPCRequest(message) && this._eventStore && clientProtocolVersion >= '2025-11-25') {
820+
if (isJSONRPCRequest(message) && this._eventStore && this.supportsEmptySSEData(clientProtocolVersion)) {
804821
closeSSEStream = () => {
805822
this.closeSSEStream(message.id);
806823
};
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { randomUUID } from 'node:crypto';
2+
3+
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/core';
4+
5+
import { McpServer } from '../../src/server/mcp.js';
6+
import type { EventId, EventStore, StreamId } from '../../src/server/streamableHttp.js';
7+
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js';
8+
9+
/**
10+
* Gate-closure tests for the two protocol-version checks that guard
11+
* resumability behavior (priming events and `closeSSEStream` callbacks).
12+
*
13+
* The protocol version in an `initialize` request body is NOT validated
14+
* against `supportedProtocolVersions` (unlike the `MCP-Protocol-Version`
15+
* header), so these gates must be bounded: only versions the transport
16+
* instance actually supports may enable the resumability behavior introduced
17+
* with protocol version 2025-11-25. An unknown future version string must
18+
* behave like a client that does not support it.
19+
*/
20+
21+
function initializeRequest(protocolVersion: string): Request {
22+
const body = {
23+
jsonrpc: '2.0',
24+
method: 'initialize',
25+
params: {
26+
clientInfo: { name: 'test-client', version: '1.0' },
27+
protocolVersion,
28+
capabilities: {}
29+
},
30+
id: 'init-1'
31+
} as JSONRPCMessage;
32+
33+
return new Request('http://localhost/mcp', {
34+
method: 'POST',
35+
headers: {
36+
'Content-Type': 'application/json',
37+
Accept: 'application/json, text/event-stream'
38+
},
39+
body: JSON.stringify(body)
40+
});
41+
}
42+
43+
async function readFirstSSEChunk(response: Response): Promise<string> {
44+
const reader = response.body?.getReader();
45+
const { value } = await reader!.read();
46+
return new TextDecoder().decode(value);
47+
}
48+
49+
/**
50+
* A priming event is an SSE event with an event ID and empty data,
51+
* e.g. `id: <eventId>\ndata: \n\n`.
52+
*/
53+
function isPrimingEvent(sseChunk: string): boolean {
54+
const lines = sseChunk.split('\n');
55+
const dataLine = lines.find(line => line.startsWith('data:'));
56+
return lines.some(line => line.startsWith('id:')) && dataLine !== undefined && dataLine.slice(5).trim() === '';
57+
}
58+
59+
describe('WebStandardStreamableHTTPServerTransport - future-version gate closure', () => {
60+
let transport: WebStandardStreamableHTTPServerTransport;
61+
let mcpServer: McpServer;
62+
let storedEvents: Map<EventId, { streamId: StreamId; message: JSONRPCMessage }>;
63+
let capturedExtras: Array<MessageExtraInfo | undefined>;
64+
65+
beforeEach(async () => {
66+
storedEvents = new Map();
67+
capturedExtras = [];
68+
69+
const eventStore: EventStore = {
70+
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
71+
const eventId = `${streamId}_${storedEvents.size}`;
72+
storedEvents.set(eventId, { streamId, message });
73+
return eventId;
74+
},
75+
async getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined> {
76+
return storedEvents.get(eventId)?.streamId;
77+
},
78+
async replayEventsAfter(): Promise<StreamId> {
79+
throw new Error('not used in these tests');
80+
}
81+
};
82+
83+
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
84+
transport = new WebStandardStreamableHTTPServerTransport({
85+
sessionIdGenerator: () => randomUUID(),
86+
eventStore
87+
});
88+
await mcpServer.connect(transport);
89+
90+
// Capture the per-message extras (closeSSEStream availability) while
91+
// preserving normal server dispatch.
92+
const serverOnMessage = transport.onmessage;
93+
transport.onmessage = (message, extra) => {
94+
capturedExtras.push(extra);
95+
serverOnMessage?.(message, extra);
96+
};
97+
});
98+
99+
afterEach(async () => {
100+
await transport.close();
101+
});
102+
103+
function expectPrimingEventStored(expected: boolean): void {
104+
// The priming event is stored as an empty message; real messages always
105+
// have at least a `jsonrpc` member.
106+
const primingStored = [...storedEvents.values()].some(event => Object.keys(event.message).length === 0);
107+
expect(primingStored).toBe(expected);
108+
}
109+
110+
describe('unknown future protocol versions in the initialize body', () => {
111+
// Far-future sentinels, deliberately not the next planned revision
112+
// (2026-07-28): these cases must stay "unknown" when real future
113+
// versions gain support, rather than silently inverting.
114+
it.each(['2099-01-01', '2099-12-31'])('does not send a priming event for protocol version %s', async futureVersion => {
115+
const response = await transport.handleRequest(initializeRequest(futureVersion));
116+
expect(response.status).toBe(200);
117+
118+
const firstChunk = await readFirstSSEChunk(response);
119+
// The first SSE event must be the initialize response, not a priming event.
120+
expect(isPrimingEvent(firstChunk)).toBe(false);
121+
expect(firstChunk).toContain('"result"');
122+
123+
expectPrimingEventStored(false);
124+
});
125+
126+
it.each(['2099-01-01', '2099-12-31'])('does not provide closeSSEStream callbacks for protocol version %s', async futureVersion => {
127+
const response = await transport.handleRequest(initializeRequest(futureVersion));
128+
expect(response.status).toBe(200);
129+
130+
expect(capturedExtras).toHaveLength(1);
131+
expect(capturedExtras[0]?.closeSSEStream).toBeUndefined();
132+
expect(capturedExtras[0]?.closeStandaloneSSEStream).toBeUndefined();
133+
});
134+
});
135+
136+
describe('existing protocol versions keep their behavior', () => {
137+
// Only 2025-11-25 (the version that introduced the empty-SSE-data fix)
138+
// takes the resumability paths - exactly as before the gates were bounded.
139+
const expectations: Array<[string, boolean]> = [
140+
['2024-10-07', false],
141+
['2024-11-05', false],
142+
['2025-03-26', false],
143+
['2025-06-18', false],
144+
['2025-11-25', true]
145+
];
146+
147+
it.each(expectations)('priming event for protocol version %s: %s', async (version, expectPriming) => {
148+
const response = await transport.handleRequest(initializeRequest(version));
149+
expect(response.status).toBe(200);
150+
151+
const firstChunk = await readFirstSSEChunk(response);
152+
expect(isPrimingEvent(firstChunk)).toBe(expectPriming);
153+
154+
expectPrimingEventStored(expectPriming);
155+
});
156+
157+
it.each(expectations)('closeSSEStream callbacks for protocol version %s: %s', async (version, expectAvailable) => {
158+
const response = await transport.handleRequest(initializeRequest(version));
159+
expect(response.status).toBe(200);
160+
161+
expect(capturedExtras).toHaveLength(1);
162+
expect(capturedExtras[0]?.closeSSEStream !== undefined).toBe(expectAvailable);
163+
expect(capturedExtras[0]?.closeStandaloneSSEStream !== undefined).toBe(expectAvailable);
164+
});
165+
});
166+
});

0 commit comments

Comments
 (0)