Skip to content

Commit 1188b1a

Browse files
feat(compat): restore SSEServerTransport under @modelcontextprotocol/node/sse
Ports the v1 SSEServerTransport to v2 as a deprecated compat shim under a dedicated /sse subpath. The implementation is a frozen copy of the v1 class with imports adapted to the v2 package layout (parseJSONRPCMessage, MessageExtraInfo.request as Web Request). Constructing the transport emits a one-time deprecation warning pointing to NodeStreamableHTTPServerTransport. The class and options interface are both marked @deprecated and slated for removal in v3. Adds content-type and raw-body as runtime deps (matching v1 body parsing).
1 parent 9ed62fe commit 1188b1a

File tree

9 files changed

+386
-10
lines changed

9 files changed

+386
-10
lines changed

.changeset/node-sse-compat-shim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/node': patch
3+
---
4+
5+
Restore the legacy `SSEServerTransport` under the `@modelcontextprotocol/node/sse` subpath as a deprecated v1-compat shim. Servers using the HTTP+SSE transport can upgrade by changing the import to `@modelcontextprotocol/node/sse` (or via the `@modelcontextprotocol/sdk` meta-package with no import change). New code should use `NodeStreamableHTTPServerTransport`.

docs/faq.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,9 @@ The SDK ships several runnable server examples under `examples/server/src`. Star
7373

7474
Server authentication & authorization is outside of the scope of the SDK, and the recommendation is to use packages that focus on this area specifically (or a full-fledged Authorization Server for those who use such). Example packages provide an example with `better-auth`.
7575

76-
### Why did we remove `server` SSE transport?
76+
### Where is the server SSE transport?
7777

78-
The SSE transport has been deprecated for a long time, and `v2` will not support it on the server side any more. Client side will keep supporting it in order to be able to connect to legacy SSE servers via the `v2` SDK, but serving SSE from `v2` will not be possible. Servers
79-
wanting to switch to `v2` and using SSE should migrate to Streamable HTTP.
78+
`SSEServerTransport` is available as a deprecated compat shim under `@modelcontextprotocol/node/sse`. New servers should migrate to Streamable HTTP (`NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`). Client side `SSEClientTransport` remains available for connecting to legacy SSE servers.
8079

8180
## v1 (legacy)
8281

docs/migration-SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table.
5353
| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` |
5454
| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server` |
5555
| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) |
56-
| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP) |
56+
| `@modelcontextprotocol/sdk/server/sse.js` | `@modelcontextprotocol/node/sse` (deprecated; new code should use `NodeStreamableHTTPServerTransport`) |
5757
| `@modelcontextprotocol/sdk/server/auth/*` | REMOVED (use external auth library) |
5858
| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) |
5959

@@ -315,7 +315,7 @@ new URL(ctx.http?.req?.url).searchParams.get('debug')
315315

316316
### SSE server transport
317317

318-
`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers.
318+
`SSEServerTransport` is available as a deprecated compat shim under `@modelcontextprotocol/node/sse`. New code should use `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers.
319319

320320
### Server-side auth
321321

@@ -501,7 +501,7 @@ Access validators explicitly:
501501
5. **Wrap all raw Zod shapes with `z.object()`**: Change `inputSchema: { name: z.string() }``inputSchema: z.object({ name: z.string() })`. Same for `outputSchema` in tools and `argsSchema` in prompts.
502502
6. Replace plain header objects with `new Headers({...})` and bracket access (`headers['x']`) with `.get()` calls per section 7
503503
7. If using `hostHeaderValidation` from server, update import and signature per section 8
504-
8. If using server SSE transport, migrate to Streamable HTTP
504+
8. If using server SSE transport, import `SSEServerTransport` from `@modelcontextprotocol/node/sse` (deprecated) or migrate to Streamable HTTP
505505
9. If using server auth from the SDK, migrate to an external auth library
506506
10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true`
507507
11. Verify: build with `tsc` / run tests

docs/migration.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,13 @@ import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
106106
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
107107
```
108108

109-
### Server-side SSE transport removed
109+
### Server-side SSE transport deprecated
110110

111-
The SSE transport has been removed from the server. Servers should migrate to Streamable HTTP. The client-side SSE transport remains available for connecting to legacy SSE servers.
111+
`SSEServerTransport` is available as a deprecated compat shim under `@modelcontextprotocol/node/sse`. New servers should use `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. The client-side SSE transport remains available for connecting to legacy SSE servers.
112+
113+
```ts
114+
import { SSEServerTransport } from '@modelcontextprotocol/node/sse';
115+
```
112116

113117
### `WebSocketClientTransport` removed
114118

packages/middleware/node/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
".": {
2525
"types": "./dist/index.d.mts",
2626
"import": "./dist/index.mjs"
27+
},
28+
"./sse": {
29+
"types": "./dist/sse.d.mts",
30+
"import": "./dist/sse.mjs"
2731
}
2832
},
2933
"files": [
@@ -43,7 +47,9 @@
4347
"client": "tsx scripts/cli.ts client"
4448
},
4549
"dependencies": {
46-
"@hono/node-server": "catalog:runtimeServerOnly"
50+
"@hono/node-server": "catalog:runtimeServerOnly",
51+
"content-type": "catalog:runtimeServerOnly",
52+
"raw-body": "catalog:runtimeServerOnly"
4753
},
4854
"peerDependencies": {
4955
"@modelcontextprotocol/server": "workspace:^",
@@ -57,6 +63,7 @@
5763
"@modelcontextprotocol/tsconfig": "workspace:^",
5864
"@modelcontextprotocol/vitest-config": "workspace:^",
5965
"@eslint/js": "catalog:devTools",
66+
"@types/content-type": "catalog:devTools",
6067
"@typescript/native-preview": "catalog:devTools",
6168
"eslint": "catalog:devTools",
6269
"eslint-config-prettier": "catalog:devTools",
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* Legacy SSE Server Transport (v1 compatibility shim)
3+
*
4+
* This module restores the v1 `SSEServerTransport` class for backwards
5+
* compatibility. It is a frozen port of the v1 implementation with imports
6+
* adapted to the v2 package layout. New code should use
7+
* `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`
8+
* instead.
9+
*
10+
* @module sse
11+
* @deprecated Use `NodeStreamableHTTPServerTransport` instead.
12+
*/
13+
14+
import { randomUUID } from 'node:crypto';
15+
import type { IncomingMessage, ServerResponse } from 'node:http';
16+
import { URL } from 'node:url';
17+
18+
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, Transport } from '@modelcontextprotocol/core';
19+
import { parseJSONRPCMessage } from '@modelcontextprotocol/server';
20+
import contentType from 'content-type';
21+
import getRawBody from 'raw-body';
22+
23+
const MAXIMUM_MESSAGE_SIZE = '4mb';
24+
25+
/**
26+
* Configuration options for `SSEServerTransport`.
27+
*
28+
* @deprecated Use `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node` instead.
29+
*/
30+
export interface SSEServerTransportOptions {
31+
/**
32+
* List of allowed host header values for DNS rebinding protection.
33+
* If not specified, host validation is disabled.
34+
*/
35+
allowedHosts?: string[];
36+
37+
/**
38+
* List of allowed origin header values for DNS rebinding protection.
39+
* If not specified, origin validation is disabled.
40+
*/
41+
allowedOrigins?: string[];
42+
43+
/**
44+
* Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured).
45+
* Default is `false` for backwards compatibility.
46+
*/
47+
enableDnsRebindingProtection?: boolean;
48+
}
49+
50+
/**
51+
* Server transport for the legacy HTTP+SSE protocol: sends messages over an SSE
52+
* connection and receives messages from separate HTTP POST requests.
53+
*
54+
* This transport is only available in Node.js environments.
55+
*
56+
* @deprecated Use `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node` instead.
57+
*/
58+
export class SSEServerTransport implements Transport {
59+
private _sseResponse?: ServerResponse;
60+
private _sessionId: string;
61+
private _options: SSEServerTransportOptions;
62+
63+
onclose?: () => void;
64+
onerror?: (error: Error) => void;
65+
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
66+
67+
/**
68+
* Creates a new SSE server transport, which will direct the client to POST
69+
* messages to the relative or absolute URL identified by `endpoint`.
70+
*/
71+
constructor(
72+
private _endpoint: string,
73+
private res: ServerResponse,
74+
options?: SSEServerTransportOptions
75+
) {
76+
this._sessionId = randomUUID();
77+
this._options = options ?? { enableDnsRebindingProtection: false };
78+
}
79+
80+
/**
81+
* Validates request headers for DNS rebinding protection.
82+
* @returns Error message if validation fails, undefined if validation passes.
83+
*/
84+
private validateRequestHeaders(req: IncomingMessage): string | undefined {
85+
// Skip validation if protection is not enabled
86+
if (!this._options.enableDnsRebindingProtection) {
87+
return undefined;
88+
}
89+
90+
// Validate Host header if allowedHosts is configured
91+
if (this._options.allowedHosts && this._options.allowedHosts.length > 0) {
92+
const hostHeader = req.headers.host;
93+
if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) {
94+
return `Invalid Host header: ${hostHeader}`;
95+
}
96+
}
97+
98+
// Validate Origin header if allowedOrigins is configured
99+
if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) {
100+
const originHeader = req.headers.origin;
101+
if (originHeader && !this._options.allowedOrigins.includes(originHeader)) {
102+
return `Invalid Origin header: ${originHeader}`;
103+
}
104+
}
105+
106+
return undefined;
107+
}
108+
109+
/**
110+
* Handles the initial SSE connection request.
111+
*
112+
* This should be called when a GET request is made to establish the SSE stream.
113+
*/
114+
async start(): Promise<void> {
115+
if (this._sseResponse) {
116+
throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.');
117+
}
118+
119+
this.res.writeHead(200, {
120+
'Content-Type': 'text/event-stream',
121+
'Cache-Control': 'no-cache, no-transform',
122+
Connection: 'keep-alive'
123+
});
124+
125+
// Send the endpoint event.
126+
// Use a dummy base URL because this._endpoint is relative.
127+
// This allows using URL/URLSearchParams for robust parameter handling.
128+
const dummyBase = 'http://localhost'; // Any valid base works
129+
const endpointUrl = new URL(this._endpoint, dummyBase);
130+
endpointUrl.searchParams.set('sessionId', this._sessionId);
131+
132+
// Reconstruct the relative URL string (pathname + search + hash)
133+
const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash;
134+
135+
this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`);
136+
137+
this._sseResponse = this.res;
138+
this.res.on('close', () => {
139+
this._sseResponse = undefined;
140+
this.onclose?.();
141+
});
142+
}
143+
144+
/**
145+
* Handles incoming POST messages.
146+
*
147+
* This should be called when a POST request is made to send a message to the server.
148+
*/
149+
async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
150+
if (!this._sseResponse) {
151+
const message = 'SSE connection not established';
152+
res.writeHead(500).end(message);
153+
throw new Error(message);
154+
}
155+
156+
// Validate request headers for DNS rebinding protection
157+
const validationError = this.validateRequestHeaders(req);
158+
if (validationError) {
159+
res.writeHead(403).end(validationError);
160+
this.onerror?.(new Error(validationError));
161+
return;
162+
}
163+
164+
const authInfo: AuthInfo | undefined = req.auth;
165+
const request = toWebRequest(req);
166+
167+
let body: string | unknown;
168+
try {
169+
const ct = contentType.parse(req.headers['content-type'] ?? '');
170+
if (ct.type !== 'application/json') {
171+
throw new Error(`Unsupported content-type: ${ct.type}`);
172+
}
173+
174+
body =
175+
parsedBody ??
176+
(await getRawBody(req, {
177+
limit: MAXIMUM_MESSAGE_SIZE,
178+
encoding: ct.parameters.charset ?? 'utf8'
179+
}));
180+
} catch (error) {
181+
res.writeHead(400).end(String(error));
182+
this.onerror?.(error as Error);
183+
return;
184+
}
185+
186+
try {
187+
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { request, authInfo });
188+
} catch {
189+
res.writeHead(400).end(`Invalid message: ${body}`);
190+
return;
191+
}
192+
193+
res.writeHead(202).end('Accepted');
194+
}
195+
196+
/**
197+
* Handle a client message, regardless of how it arrived. This can be used to
198+
* inform the server of messages that arrive via a means different than HTTP POST.
199+
*/
200+
async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise<void> {
201+
let parsedMessage: JSONRPCMessage;
202+
try {
203+
parsedMessage = parseJSONRPCMessage(message);
204+
} catch (error) {
205+
this.onerror?.(error as Error);
206+
throw error;
207+
}
208+
209+
this.onmessage?.(parsedMessage, extra);
210+
}
211+
212+
async close(): Promise<void> {
213+
this._sseResponse?.end();
214+
this._sseResponse = undefined;
215+
this.onclose?.();
216+
}
217+
218+
async send(message: JSONRPCMessage): Promise<void> {
219+
if (!this._sseResponse) {
220+
throw new Error('Not connected');
221+
}
222+
223+
this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
224+
}
225+
226+
/**
227+
* Returns the session ID for this transport.
228+
*
229+
* This can be used to route incoming POST requests.
230+
*/
231+
get sessionId(): string {
232+
return this._sessionId;
233+
}
234+
}
235+
236+
/**
237+
* Builds a Web-standard {@linkcode Request} (URL + headers only) from a Node
238+
* {@linkcode IncomingMessage} so v2 handler contexts can read `extra.request`.
239+
* The body is omitted because the transport consumes it separately.
240+
*/
241+
function toWebRequest(req: IncomingMessage): globalThis.Request | undefined {
242+
const host = req.headers.host;
243+
if (!host || !req.url) {
244+
return undefined;
245+
}
246+
// We can't reliably detect TLS at this layer (proxies, etc.); the scheme is
247+
// best-effort and only matters for handler-side URL inspection.
248+
const isEncrypted = (req.socket as { encrypted?: boolean } | undefined)?.encrypted === true;
249+
const protocol = isEncrypted ? 'https' : 'http';
250+
const headers = new Headers();
251+
for (const [key, value] of Object.entries(req.headers)) {
252+
if (value === undefined) continue;
253+
headers.set(key, Array.isArray(value) ? value.join(', ') : value);
254+
}
255+
return new Request(new URL(req.url, `${protocol}://${host}`), {
256+
method: req.method,
257+
headers
258+
});
259+
}

0 commit comments

Comments
 (0)