Skip to content

Commit c54abdb

Browse files
test(e2e): host the sse matrix column on the shipped legacy SSEServerTransport (#2229)
1 parent e03bca9 commit c54abdb

8 files changed

Lines changed: 101 additions & 97 deletions

File tree

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/e2e/helpers/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
77
* no real sockets, no child processes — except the legacy SSE transport,
88
* whose client half opens a real EventSource stream, so it runs over a
9-
* loopback HTTP listener backed by the test-only bridge in sse-host.ts.
9+
* loopback HTTP listener hosting the shipped server-side SSE transport
10+
* (see sse-host.ts).
1011
*/
1112

1213
import { randomUUID } from 'node:crypto';
@@ -67,8 +68,9 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie
6768
};
6869
}
6970
case 'sse': {
70-
// v2 removed the server-side SSE transport, so the factory's server is hosted behind the
71-
// test-only bridge in sse-host.ts and the real shipped SSEClientTransport connects to it.
71+
// The legacy SSE transport needs a real socket: the factory's server is hosted on the
72+
// shipped SSEServerTransport (@modelcontextprotocol/server-legacy/sse) behind a loopback
73+
// listener, and the real shipped SSEClientTransport connects to it.
7274
const host = await startLegacySseHost(makeServer);
7375
await client.connect(sniffTransport(new SSEClientTransport(host.url), 'client', sniff));
7476
return {

test/e2e/helpers/sse-host.ts

Lines changed: 10 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
/**
2-
* Test-only legacy HTTP+SSE host bridge.
2+
* Legacy HTTP+SSE host.
33
*
4-
* v2 removed the server-side SSE transport, but the client-side
5-
* SSEClientTransport is still shipped for talking to legacy servers. This
6-
* bridge stands in for the removed server half so the e2e matrix can exercise
7-
* the real client transport end to end: it speaks the legacy wire protocol
8-
* (an `endpoint` SSE event carrying the POST URL with the sessionId,
9-
* `message` SSE events for server→client JSON-RPC, plain POSTs for
10-
* client→server JSON-RPC) over a real loopback listener and bridges to a real
11-
* v2 server through the Transport interface.
4+
* Hosts the SDK's shipped server-side SSE transport (`SSEServerTransport` from
5+
* `@modelcontextprotocol/server-legacy/sse`) on a real loopback listener so the
6+
* e2e matrix exercises both shipped halves of the legacy transport end to end:
7+
* GET opens the SSE stream (an `endpoint` event carries the POST URL with the
8+
* sessionId), POSTs deliver client→server JSON-RPC to the owning session.
129
*/
1310

14-
import { randomUUID } from 'node:crypto';
1511
import type { IncomingMessage, ServerResponse } from 'node:http';
1612
import { createServer } from 'node:http';
1713

18-
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
19-
import type { JSONRPCMessage, McpServer, Server, Transport } from '@modelcontextprotocol/server';
14+
import type { McpServer, Server } from '@modelcontextprotocol/server';
15+
import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse';
2016

2117
const SSE_PATH = '/sse';
2218
const POST_PATH = '/messages';
@@ -27,74 +23,6 @@ function toError(value: unknown): Error {
2723
return value instanceof Error ? value : new Error(String(value));
2824
}
2925

30-
/** Test-only server half of the legacy HTTP+SSE transport (v2 ships only the client half). */
31-
export class LegacySseServerTransport implements Transport {
32-
private _response?: ServerResponse;
33-
readonly sessionId: string = randomUUID();
34-
35-
onclose?: () => void;
36-
onerror?: (error: Error) => void;
37-
onmessage?: (message: JSONRPCMessage) => void;
38-
39-
constructor(private readonly _res: ServerResponse) {}
40-
41-
async start(): Promise<void> {
42-
if (this._response) throw new Error('LegacySseServerTransport already started');
43-
this._res.writeHead(200, {
44-
'content-type': 'text/event-stream',
45-
'cache-control': 'no-cache, no-transform',
46-
connection: 'keep-alive'
47-
});
48-
// The legacy protocol's first event tells the client where to POST its messages.
49-
this._res.write(`event: endpoint\ndata: ${POST_PATH}?sessionId=${this.sessionId}\n\n`);
50-
this._response = this._res;
51-
this._res.on('close', () => {
52-
this._response = undefined;
53-
this.onclose?.();
54-
});
55-
}
56-
57-
async send(message: JSONRPCMessage): Promise<void> {
58-
if (!this._response) throw new Error('SSE stream not established');
59-
this._response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
60-
}
61-
62-
async close(): Promise<void> {
63-
this._response?.end();
64-
this._response = undefined;
65-
this.onclose?.();
66-
}
67-
68-
/** Delivers a client→server POST to the server side and answers the HTTP request (202 on success). */
69-
async handlePostMessage(req: IncomingMessage, res: ServerResponse): Promise<void> {
70-
if (!this._response) {
71-
res.writeHead(500).end('SSE connection not established');
72-
return;
73-
}
74-
const contentTypeHeader = req.headers['content-type'] ?? '';
75-
if (!contentTypeHeader.includes('application/json')) {
76-
res.writeHead(400).end(`Unsupported content-type: ${contentTypeHeader}`);
77-
return;
78-
}
79-
const chunks: Buffer[] = [];
80-
for await (const chunk of req) {
81-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
82-
}
83-
const raw = Buffer.concat(chunks).toString('utf8');
84-
85-
let message: JSONRPCMessage;
86-
try {
87-
message = JSONRPCMessageSchema.parse(JSON.parse(raw));
88-
} catch (error) {
89-
res.writeHead(400).end(`Invalid message: ${raw}`);
90-
this.onerror?.(toError(error));
91-
return;
92-
}
93-
res.writeHead(202).end('Accepted');
94-
this.onmessage?.(message);
95-
}
96-
}
97-
9826
export interface LegacySseHost {
9927
/** URL of the SSE endpoint; GET it to open a stream. */
10028
readonly url: URL;
@@ -108,12 +36,12 @@ export interface LegacySseHost {
10836
* become 500.
10937
*/
11038
export async function startLegacySseHost(makeServer: () => AnyServer): Promise<LegacySseHost> {
111-
const sessions = new Map<string, { tx: LegacySseServerTransport; server: AnyServer }>();
39+
const sessions = new Map<string, { tx: SSEServerTransport; server: AnyServer }>();
11240

11341
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
11442
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
11543
if (req.method === 'GET' && requestUrl.pathname === SSE_PATH) {
116-
const tx = new LegacySseServerTransport(res);
44+
const tx = new SSEServerTransport(POST_PATH, res);
11745
const server = makeServer();
11846
sessions.set(tx.sessionId, { tx, server });
11947
// connect() starts the transport, which writes the SSE headers and the endpoint event.

test/e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@modelcontextprotocol/client": "workspace:^",
3535
"@modelcontextprotocol/core": "workspace:^",
3636
"@modelcontextprotocol/server": "workspace:^",
37+
"@modelcontextprotocol/server-legacy": "workspace:^",
3738
"@modelcontextprotocol/express": "workspace:^",
3839
"@modelcontextprotocol/fastify": "workspace:^",
3940
"@modelcontextprotocol/hono": "workspace:^",

test/e2e/requirements.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2268,16 +2268,14 @@ export const REQUIREMENTS: Record<string, Requirement> = {
22682268
behavior:
22692269
'A single server instance can serve streamable HTTP and the legacy SSE transport concurrently; clients on either transport can call the same tools.',
22702270
transports: ['streamableHttp'],
2271-
note: 'Deferred flows test legacy SSE; transport restriction reflects test infrastructure, not behavioral exclusion.',
2272-
deferred: 'Legacy SSE transport is deprecated in the spec. Back-compat flows that require an SSE server are deferred.'
2271+
note: 'This is an HTTP-specific compatibility flow; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The SSE half is hosted with SSEServerTransport from @modelcontextprotocol/server-legacy/sse.'
22732272
},
22742273
'flow:compat:streamable-then-sse-fallback': {
22752274
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#backwards-compatibility',
22762275
behavior:
22772276
'When a streamable HTTP initialize fails with 400, 404, or 405, falling back to the legacy SSE client transport against the same server connects successfully.',
22782277
transports: ['streamableHttp'],
2279-
note: 'This is an HTTP-specific compatibility flow; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.',
2280-
deferred: 'Legacy SSE transport is deprecated in the spec. Back-compat flows that require an SSE server are deferred.'
2278+
note: 'This is an HTTP-specific compatibility flow; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
22812279
},
22822280
'flow:elicitation:multi-step-form': {
22832281
transports: STATEFUL_TRANSPORTS,
@@ -2717,12 +2715,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
27172715
behavior:
27182716
'The SDK provides a server-side legacy HTTP+SSE transport so existing SSE deployments can be hosted on SDK components alone.',
27192717
transports: ['sse'],
2720-
note: 'This asserts the availability of the server half of the legacy SSE transport; the matrix transport arg is ignored, so it runs as a single sse-labelled cell.',
2721-
knownFailures: [
2722-
{
2723-
note: 'changed in v2: the server-side SSE transport was removed from the SDK; only the client-side SSEClientTransport remains, so the e2e sse column is hosted by a test-only bridge.'
2724-
}
2725-
]
2718+
note: 'This asserts the availability of the server half of the legacy SSE transport (SSEServerTransport from @modelcontextprotocol/server-legacy/sse); the matrix transport arg is ignored, so it runs as a single sse-labelled cell.'
27262719
}
27272720
} satisfies Record<string, Requirement>;
27282721

test/e2e/scenarios/flow.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
12-
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
12+
import { Client, SdkHttpError, SSEClientTransport, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
1313
import type {
1414
ElicitRequest,
1515
ElicitResult,
@@ -25,9 +25,84 @@ import { expect, vi } from 'vitest';
2525
import { z } from 'zod/v4';
2626

2727
import { hostPerSession, hostResumable, wire } from '../helpers/index.js';
28+
import { startLegacySseHost } from '../helpers/sse-host.js';
2829
import { verifies } from '../helpers/verifies.js';
2930
import type { TestArgs } from '../types.js';
3031

32+
verifies('flow:compat:dual-transport-server', async (_args: TestArgs) => {
33+
// One deployment, one server factory, both transports: the modern Streamable HTTP
34+
// endpoint and the legacy SSE endpoint serve the same tools to concurrent clients.
35+
const makeServer = () => {
36+
const s = new McpServer({ name: 'dual-transport', version: '1.0.0' });
37+
s.registerTool('add', { inputSchema: z.object({ a: z.number(), b: z.number() }) }, ({ a, b }) => ({
38+
content: [{ type: 'text', text: String(a + b) }]
39+
}));
40+
return s;
41+
};
42+
43+
const streamableHost = hostPerSession(makeServer);
44+
const sseHost = await startLegacySseHost(makeServer);
45+
try {
46+
const httpClient = new Client({ name: 'streamable-client', version: '1.0.0' });
47+
const fetch = (u: URL | string, init?: RequestInit) => streamableHost.handleRequest(new Request(u, init));
48+
await httpClient.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch }));
49+
50+
const sseClient = new Client({ name: 'sse-client', version: '1.0.0' });
51+
await sseClient.connect(new SSEClientTransport(sseHost.url));
52+
53+
// Both clients are connected at the same time and reach the same tool surface.
54+
const [httpResult, sseResult] = await Promise.all([
55+
httpClient.callTool({ name: 'add', arguments: { a: 1, b: 2 } }),
56+
sseClient.callTool({ name: 'add', arguments: { a: 3, b: 4 } })
57+
]);
58+
expect(httpResult.content).toEqual([{ type: 'text', text: '3' }]);
59+
expect(sseResult.content).toEqual([{ type: 'text', text: '7' }]);
60+
61+
await httpClient.close();
62+
await sseClient.close();
63+
} finally {
64+
await streamableHost.close();
65+
await sseHost.close();
66+
}
67+
});
68+
69+
verifies('flow:compat:streamable-then-sse-fallback', async (_args: TestArgs) => {
70+
// An SSE-only deployment (a pre-Streamable-HTTP server). A client that prefers
71+
// Streamable HTTP gets a 4xx on initialize and falls back to the legacy SSE
72+
// client transport against the same URL, per the spec's backwards-compatibility
73+
// guidance for clients.
74+
const makeServer = () => {
75+
const s = new McpServer({ name: 'sse-only', version: '1.0.0' });
76+
s.registerTool('greet', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'hello' }] }));
77+
return s;
78+
};
79+
80+
const host = await startLegacySseHost(makeServer);
81+
try {
82+
// 1. Streamable HTTP attempt: POSTing initialize to the legacy SSE endpoint fails with a 4xx.
83+
const streamableClient = new Client({ name: 'fallback-client', version: '1.0.0' });
84+
let rejection: unknown;
85+
try {
86+
await streamableClient.connect(new StreamableHTTPClientTransport(host.url));
87+
} catch (error) {
88+
rejection = error;
89+
}
90+
expect(rejection).toBeInstanceOf(SdkHttpError);
91+
if (!(rejection instanceof SdkHttpError)) throw new Error('rejection is not an SdkHttpError');
92+
// 400/404/405 is the signal to fall back to the legacy transport.
93+
expect([400, 404, 405]).toContain(rejection.status);
94+
95+
// 2. Fall back to the legacy SSE client transport against the same server.
96+
const sseClient = new Client({ name: 'fallback-client', version: '1.0.0' });
97+
await sseClient.connect(new SSEClientTransport(host.url));
98+
const result = await sseClient.callTool({ name: 'greet', arguments: {} });
99+
expect(result.content).toEqual([{ type: 'text', text: 'hello' }]);
100+
await sseClient.close();
101+
} finally {
102+
await host.close();
103+
}
104+
});
105+
31106
verifies('flow:elicitation:multi-step-form', async ({ transport }: TestArgs) => {
32107
// Server: tool that issues three sequential elicitation/create requests
33108
const makeServer = () => {

test/e2e/scenarios/transport-sse.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ verifies('transport:sse:server-transport', async (_args: TestArgs) => {
1616
const surfaces = await Promise.all([
1717
import('@modelcontextprotocol/server'),
1818
import('@modelcontextprotocol/node'),
19-
import('@modelcontextprotocol/express')
19+
import('@modelcontextprotocol/express'),
20+
import('@modelcontextprotocol/server-legacy/sse')
2021
]);
2122
const exported = surfaces.flatMap(surface => Object.keys(surface));
2223
const sseServerExports = exported.filter(name => /sse/i.test(name) && /server/i.test(name));

test/e2e/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
2121
"@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"],
2222
"@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"],
23+
"@modelcontextprotocol/server-legacy/sse": ["./node_modules/@modelcontextprotocol/server-legacy/src/sse/index.ts"],
2324
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
2425
"@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"],
2526
"@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"],

0 commit comments

Comments
 (0)