Skip to content

Commit b322ca0

Browse files
committed
hosted: run sse-retry on val.town
sse-retry was excluded as single-process only. Two things kept it from working across isolates: - The reconnecting GET stays open for as long as the client listens, and the fetch bridge buffered every response until end(), so the client never received the tool result. The bridge now hands a server-sent event stream back at its first write and feeds later writes into it (cancelling it emits "close" on the response, as a socket would); listenFetch streams bodies too. Because such a stream can outlive the handler, dispatch writes the cell through on each event it sends, and withServerTiming holds the stream's end until the store flush. - The GET could reach an isolate that never saw the tool call, and the verdict compared performance.now() readings a fresh judging instance does not have. The scenario now sets answersFromLog and reads the pending tool call, the next event id and the GETs so far from its log, echoes the session id the client sends, and, when judging a merged log, reads the reconnect delay from the checks' timestamps. In one process the log says what memory did, so the CLI runs as before. Only elicitation-sep1034-client-defaults stays excluded on val.town. A val.town-shaped test drives the SDK client through one process, two sharing a store and two that forget every cell after each request, and fails a client that reconnects early whichever process it reaches.
1 parent 291314d commit b322ca0

11 files changed

Lines changed: 556 additions & 55 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { toFetchHandler } from './fetch-bridge';
3+
4+
const text = (b: Uint8Array | undefined) => new TextDecoder().decode(b);
5+
6+
describe('fetch bridge', () => {
7+
it('hands back a server-sent event stream at its first write, and tells the listener when the client leaves', async () => {
8+
let closed = false;
9+
let later!: () => void;
10+
const handle = toFetchHandler((_req, res) => {
11+
res.on('close', () => {
12+
closed = true;
13+
});
14+
res.writeHead(200, { 'content-type': 'text/event-stream' });
15+
res.write('data: one\n\n');
16+
later = () => res.write('data: two\n\n');
17+
});
18+
// Resolves although the listener never ends the response.
19+
const r = await handle(new Request('http://test/'));
20+
expect(r.status).toBe(200);
21+
expect(r.headers.get('content-type')).toBe('text/event-stream');
22+
const reader = r.body!.getReader();
23+
expect(text((await reader.read()).value)).toBe('data: one\n\n');
24+
later();
25+
expect(text((await reader.read()).value)).toBe('data: two\n\n');
26+
await reader.cancel();
27+
expect(closed).toBe(true);
28+
});
29+
30+
it('closes the stream when the listener ends it', async () => {
31+
const handle = toFetchHandler((_req, res) => {
32+
res.setHeader('content-type', 'text/event-stream');
33+
res.write('data: one\n\n');
34+
setTimeout(() => res.end('data: last\n\n'), 10);
35+
});
36+
const r = await handle(new Request('http://test/'));
37+
expect(await r.text()).toBe('data: one\n\ndata: last\n\n');
38+
});
39+
40+
it('buffers any other response until end()', async () => {
41+
let ended = false;
42+
const handle = toFetchHandler((_req, res) => {
43+
res.writeHead(200, { 'content-type': 'application/json' });
44+
res.write('{"a":');
45+
setTimeout(() => {
46+
ended = true;
47+
res.end('1}');
48+
}, 10);
49+
});
50+
const r = await handle(new Request('http://test/'));
51+
expect(ended).toBe(true);
52+
expect(await r.json()).toEqual({ a: 1 });
53+
});
54+
});

‎examples/hosted/fetch-bridge.ts‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ export function toFetchHandler(
8181
captureHeaders(h);
8282
return nodeRes;
8383
}) as ServerResponse['writeHead'];
84-
nodeRes.write = ((c: string | Buffer, enc?: BufferEncoding) => {
85-
if (c) chunks.push(typeof c === 'string' ? Buffer.from(c, enc) : c);
86-
return true;
87-
}) as ServerResponse['write'];
8884
nodeRes.flushHeaders = () => {};
8985
Object.defineProperty(nodeRes, 'statusCode', {
9086
get: () => status,
@@ -93,9 +89,53 @@ export function toFetchHandler(
9389
}
9490
});
9591

92+
// A server-sent event stream can stay open for as long as the client
93+
// listens (a GET stream, a response held open), so it is handed back at
94+
// its first write and fed as the listener writes; any other response is
95+
// buffered until end().
96+
let stream: ReadableStreamDefaultController<Uint8Array> | undefined;
97+
let cancelled = false;
98+
const isEventStream = () =>
99+
(headers.get('content-type') ?? '').includes('text/event-stream');
100+
96101
return new Promise<Response>((resolve) => {
97-
nodeRes.end = ((c?: string | Buffer, enc?: BufferEncoding) => {
98-
if (c) chunks.push(typeof c === 'string' ? Buffer.from(c, enc) : c);
102+
const push = (c: string | Uint8Array, enc?: BufferEncoding) => {
103+
const buf = typeof c === 'string' ? Buffer.from(c, enc) : c;
104+
if (!stream) {
105+
chunks.push(Buffer.from(buf));
106+
return;
107+
}
108+
if (!cancelled) stream.enqueue(buf);
109+
};
110+
nodeRes.write = ((c: string | Uint8Array, enc?: BufferEncoding) => {
111+
if (!c) return true;
112+
push(c, enc);
113+
if (!stream && isEventStream()) {
114+
resolve(
115+
new Response(
116+
new ReadableStream<Uint8Array>({
117+
start(controller) {
118+
stream = controller;
119+
for (const b of chunks.splice(0)) controller.enqueue(b);
120+
},
121+
// The client went away: tell the listener, as a socket would.
122+
cancel() {
123+
cancelled = true;
124+
nodeRes.emit('close');
125+
}
126+
}),
127+
{ status, headers }
128+
)
129+
);
130+
}
131+
return true;
132+
}) as ServerResponse['write'];
133+
nodeRes.end = ((c?: string | Uint8Array, enc?: BufferEncoding) => {
134+
if (c) push(c, enc);
135+
if (stream) {
136+
if (!cancelled) stream.close();
137+
return nodeRes;
138+
}
99139
resolve(
100140
new Response(chunks.length ? Buffer.concat(chunks) : null, {
101141
status,

‎examples/hosted/local-relay.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,17 @@ export function listenFetch(
4040
})
4141
);
4242
res.writeHead(out.status, Object.fromEntries(out.headers));
43-
res.end(Buffer.from(await out.arrayBuffer()));
43+
// Passed on as it arrives: a server-sent event stream may stay open.
44+
if (out.body) {
45+
const reader = out.body.getReader();
46+
res.on('close', () => void reader.cancel().catch(() => {}));
47+
for (;;) {
48+
const { done, value } = await reader.read();
49+
if (done) break;
50+
res.write(value);
51+
}
52+
}
53+
res.end();
4454
});
4555
return new Promise((resolve) => server.listen(port, () => resolve(server)));
4656
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* sse-retry on a val.town-shaped deployment: each hosted app behind the
3+
* fetch bridge and withServerTiming, as valtown.ts serves it, with requests
4+
* dealt round-robin between apps that share only a store. The tool call and
5+
* the GET that resumes it can then reach different processes.
6+
*/
7+
8+
import { describe, it, expect, afterEach } from 'vitest';
9+
import type { Server } from 'http';
10+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
12+
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
13+
import { createHostedApp } from '../../src/hosted/server';
14+
import { MemoryRunStore } from '../../src/hosted/store';
15+
import { toFetchHandler } from './fetch-bridge';
16+
import { listenFetch } from './local-relay';
17+
import { withServerTiming } from './valtown';
18+
19+
const REV = '2025-11-25';
20+
21+
interface Deployment {
22+
origin: string;
23+
apps: ReturnType<typeof createHostedApp>[];
24+
server: Server;
25+
}
26+
27+
const open: Deployment[] = [];
28+
29+
async function deploy(
30+
processes: number,
31+
{ store = false, evict = false } = {}
32+
): Promise<Deployment> {
33+
const shared = store ? new MemoryRunStore() : undefined;
34+
const apps = Array.from({ length: processes }, () =>
35+
createHostedApp({ store: shared })
36+
);
37+
const handlers = apps.map(({ app, sessions }) => {
38+
const handle = withServerTiming(
39+
toFetchHandler(app),
40+
() => sessions.flush(),
41+
0
42+
);
43+
return async (req: Request) => {
44+
const res = await handle(req);
45+
// An isolate that drops every cell between requests.
46+
if (evict) {
47+
for (const run of sessions.list())
48+
await sessions.destroy(run.id, false);
49+
}
50+
return res;
51+
};
52+
});
53+
let turn = 0;
54+
const server = await listenFetch(0, (req) =>
55+
handlers[turn++ % handlers.length](req)
56+
);
57+
const dep = {
58+
origin: `http://localhost:${(server.address() as { port: number }).port}`,
59+
apps,
60+
server
61+
};
62+
open.push(dep);
63+
return dep;
64+
}
65+
66+
afterEach(async () => {
67+
for (const dep of open.splice(0)) {
68+
for (const { sessions } of dep.apps) await sessions.close();
69+
dep.server.closeAllConnections?.();
70+
await new Promise<void>((r) => dep.server.close(() => r()));
71+
}
72+
});
73+
74+
async function results(dep: Deployment, cell: string) {
75+
for (const { sessions } of dep.apps) await sessions.flush();
76+
return fetch(`${dep.origin}/results/${cell}`).then((r) =>
77+
r.json()
78+
) as Promise<{
79+
verdict: string;
80+
checks: Array<{
81+
id: string;
82+
status: string;
83+
details?: Record<string, unknown>;
84+
}>;
85+
}>;
86+
}
87+
88+
const statusOf = (checks: Array<{ id: string; status: string }>, id: string) =>
89+
checks.find((c) => c.id === id)?.status;
90+
91+
describe.each([
92+
{ label: 'one process', processes: 1, store: false, evict: false },
93+
{
94+
label: 'two processes sharing a store',
95+
processes: 2,
96+
store: true,
97+
evict: false
98+
},
99+
{
100+
label: 'two processes that forget every cell after each request',
101+
processes: 2,
102+
store: true,
103+
evict: true
104+
}
105+
])('sse-retry through the val.town entry ($label)', (opts) => {
106+
it('passes the SDK client, whichever process the reconnect reaches', async () => {
107+
const dep = await deploy(opts.processes, opts);
108+
const cell = `sse/${REV}/sse-retry`;
109+
// What examples/clients/typescript/sse-retry-test.ts does.
110+
const client = new Client(
111+
{ name: 'sse-retry-test-client', version: '1.0.0' },
112+
{ capabilities: {} }
113+
);
114+
const transport = new StreamableHTTPClientTransport(
115+
new URL(`${dep.origin}/s/${cell}/mcp`),
116+
{
117+
reconnectionOptions: {
118+
initialReconnectionDelay: 1000,
119+
maxReconnectionDelay: 10000,
120+
reconnectionDelayGrowFactor: 1.5,
121+
maxRetries: 3
122+
}
123+
}
124+
);
125+
await client.connect(transport);
126+
const result = await client.request(
127+
{
128+
method: 'tools/call',
129+
params: { name: 'test_reconnection', arguments: {} }
130+
},
131+
CallToolResultSchema
132+
);
133+
expect(result.content).toEqual([
134+
{ type: 'text', text: 'Reconnection test completed successfully' }
135+
]);
136+
await transport.close();
137+
138+
const { verdict, checks } = await results(dep, cell);
139+
expect(checks.filter((c) => c.status === 'FAILURE')).toEqual([]);
140+
expect({
141+
verdict,
142+
reconnect: statusOf(checks, 'client-sse-graceful-reconnect'),
143+
timing: statusOf(checks, 'client-sse-retry-timing'),
144+
lastEventId: statusOf(checks, 'client-sse-last-event-id')
145+
}).toEqual({
146+
verdict: 'pass',
147+
reconnect: 'SUCCESS',
148+
timing: 'SUCCESS',
149+
lastEventId: 'SUCCESS'
150+
});
151+
}, 20_000);
152+
});
153+
154+
describe('sse-retry across processes, by hand', () => {
155+
it('answers the tool call on the other process and still fails a reconnect that comes too early', async () => {
156+
// Requests alternate between the two processes: initialize and the tool
157+
// call reach the first, the notification and the GET the second.
158+
const dep = await deploy(2, { store: true });
159+
const cell = `early/${REV}/sse-retry`;
160+
const url = `${dep.origin}/s/${cell}/mcp`;
161+
const headers = {
162+
'content-type': 'application/json',
163+
accept: 'application/json, text/event-stream',
164+
'mcp-protocol-version': REV
165+
};
166+
const init = await fetch(url, {
167+
method: 'POST',
168+
headers,
169+
body: JSON.stringify({
170+
jsonrpc: '2.0',
171+
id: 1,
172+
method: 'initialize',
173+
params: {
174+
protocolVersion: REV,
175+
clientInfo: { name: 'early', version: '0' },
176+
capabilities: {}
177+
}
178+
})
179+
});
180+
await init.text();
181+
const session = { 'mcp-session-id': init.headers.get('mcp-session-id')! };
182+
await fetch(url, {
183+
method: 'POST',
184+
headers: { ...headers, ...session },
185+
body: JSON.stringify({
186+
jsonrpc: '2.0',
187+
method: 'notifications/initialized'
188+
})
189+
}).then((r) => r.text());
190+
// The tool call's stream: a priming event, then closed by the server.
191+
const call = await fetch(url, {
192+
method: 'POST',
193+
headers: { ...headers, ...session },
194+
body: JSON.stringify({
195+
jsonrpc: '2.0',
196+
id: 7,
197+
method: 'tools/call',
198+
params: { name: 'test_reconnection', arguments: {} }
199+
})
200+
});
201+
expect(await call.text()).toContain('id: event-1');
202+
// Reconnect at once, ignoring the 500 ms retry.
203+
const get = await fetch(url, {
204+
headers: {
205+
accept: 'text/event-stream',
206+
'mcp-protocol-version': REV,
207+
'last-event-id': 'event-1',
208+
...session
209+
}
210+
});
211+
expect(get.headers.get('mcp-session-id')).toBe(session['mcp-session-id']);
212+
const reader = get.body!.getReader();
213+
let seen = '';
214+
while (!seen.includes('Reconnection test completed')) {
215+
const { done, value } = await reader.read();
216+
if (done) break;
217+
seen += new TextDecoder().decode(value);
218+
}
219+
await reader.cancel();
220+
// Answered on the process that never saw the tool call, with its id.
221+
expect(seen).toContain('"id":7');
222+
expect(seen).toContain('id: event-3');
223+
224+
const { checks } = await results(dep, cell);
225+
const timing = checks.find((c) => c.id === 'client-sse-retry-timing');
226+
expect(timing?.status).toBe('FAILURE');
227+
expect(timing?.details?.actualDelayMs as number).toBeLessThan(450);
228+
expect(statusOf(checks, 'client-sse-last-event-id')).toBe('SUCCESS');
229+
});
230+
});

0 commit comments

Comments
 (0)