Skip to content

Commit 9624cc3

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 9624cc3

File tree

6 files changed

+428
-2
lines changed

6 files changed

+428
-2
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. Existing servers using the HTTP+SSE transport can upgrade without code changes; a one-time deprecation warning points to `NodeStreamableHTTPServerTransport`.

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

0 commit comments

Comments
 (0)