Skip to content

Commit 4a5c863

Browse files
Add consumer-sourced e2e requirements and the legacy SSE matrix column (#2203)
1 parent 1998a18 commit 4a5c863

13 files changed

Lines changed: 1431 additions & 11 deletions

.changeset/add-consumer-sse-e2e.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/test-e2e': patch
3+
---
4+
5+
Add consumer-sourced e2e requirements (behaviors real SDK dependents rely on) and run the interaction matrix over the legacy HTTP+SSE transport, with known failures recording where v2 intentionally differs.

test/e2e/helpers/index.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@
44
* `wire(transport, makeServer, client)` connects a server (built per call by
55
* `makeServer`) and a client over the named transport, returning an
66
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
7-
* no real sockets, no child processes.
7+
* no real sockets, no child processes — except the legacy SSE transport,
8+
* 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.
810
*/
911

1012
import { randomUUID } from 'node:crypto';
1113
import { PassThrough } from 'node:stream';
1214

1315
import type { Client } from '@modelcontextprotocol/client';
14-
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
16+
import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
1517
import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server';
1618
import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
1719
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
1820

1921
import type { Transport } from '../types.js';
22+
import { startLegacySseHost } from './sse-host.js';
2023
import type { SnifferOptions } from './wire-sniffer.js';
2124
import { sniffTransport } from './wire-sniffer.js';
2225

@@ -63,6 +66,19 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie
6366
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
6467
};
6568
}
69+
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.
72+
const host = await startLegacySseHost(makeServer);
73+
await client.connect(sniffTransport(new SSEClientTransport(host.url), 'client', sniff));
74+
return {
75+
url: host.url,
76+
[Symbol.asyncDispose]: async () => {
77+
await client.close();
78+
await host.close();
79+
}
80+
};
81+
}
6682
}
6783
}
6884

test/e2e/helpers/sse-host.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* Test-only legacy HTTP+SSE host bridge.
3+
*
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.
12+
*/
13+
14+
import { randomUUID } from 'node:crypto';
15+
import type { IncomingMessage, ServerResponse } from 'node:http';
16+
import { createServer } from 'node:http';
17+
18+
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
19+
import type { JSONRPCMessage, McpServer, Server, Transport } from '@modelcontextprotocol/server';
20+
21+
const SSE_PATH = '/sse';
22+
const POST_PATH = '/messages';
23+
24+
type AnyServer = McpServer | Server;
25+
26+
function toError(value: unknown): Error {
27+
return value instanceof Error ? value : new Error(String(value));
28+
}
29+
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+
98+
export interface LegacySseHost {
99+
/** URL of the SSE endpoint; GET it to open a stream. */
100+
readonly url: URL;
101+
close(): Promise<void>;
102+
}
103+
104+
/**
105+
* Runs a loopback legacy-SSE host: every GET on the SSE path gets its own
106+
* server instance from the factory (mirroring hostPerSession), POSTs are
107+
* routed to the owning session, unknown sessions get 404 and handler failures
108+
* become 500.
109+
*/
110+
export async function startLegacySseHost(makeServer: () => AnyServer): Promise<LegacySseHost> {
111+
const sessions = new Map<string, { tx: LegacySseServerTransport; server: AnyServer }>();
112+
113+
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
114+
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
115+
if (req.method === 'GET' && requestUrl.pathname === SSE_PATH) {
116+
const tx = new LegacySseServerTransport(res);
117+
const server = makeServer();
118+
sessions.set(tx.sessionId, { tx, server });
119+
// connect() starts the transport, which writes the SSE headers and the endpoint event.
120+
await server.connect(tx);
121+
return;
122+
}
123+
if (req.method === 'POST' && requestUrl.pathname === POST_PATH) {
124+
const session = sessions.get(requestUrl.searchParams.get('sessionId') ?? '');
125+
if (!session) {
126+
res.writeHead(404, { 'content-type': 'text/plain' }).end('Session not found');
127+
return;
128+
}
129+
await session.tx.handlePostMessage(req, res);
130+
return;
131+
}
132+
res.writeHead(404).end();
133+
};
134+
135+
const httpServer = createServer((req, res) => {
136+
handle(req, res).catch((error: unknown) => {
137+
// Handler failures become a 500 rather than an unhandled rejection.
138+
if (!res.headersSent) res.writeHead(500).end(toError(error).message);
139+
});
140+
});
141+
142+
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
143+
const address = httpServer.address();
144+
if (address === null || typeof address === 'string') throw new Error('expected the SSE host to listen on a TCP port');
145+
const url = new URL(`http://127.0.0.1:${address.port}${SSE_PATH}`);
146+
147+
return {
148+
url,
149+
close: async () => {
150+
for (const { tx, server } of sessions.values()) {
151+
await server.close();
152+
await tx.close();
153+
}
154+
sessions.clear();
155+
httpServer.closeAllConnections();
156+
await new Promise<void>(resolve => httpServer.close(() => resolve()));
157+
}
158+
};
159+
}

0 commit comments

Comments
 (0)