mcpose 2.1.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +461 -62
  2. package/dist/backendClient.d.ts +1 -1
  3. package/dist/backendClient.d.ts.map +1 -1
  4. package/dist/backendClient.js +6 -1
  5. package/dist/backendClient.js.map +1 -1
  6. package/dist/core.d.ts +309 -35
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/core.js +1023 -186
  9. package/dist/core.js.map +1 -1
  10. package/dist/delegation.d.ts +89 -0
  11. package/dist/delegation.d.ts.map +1 -0
  12. package/dist/delegation.js +175 -0
  13. package/dist/delegation.js.map +1 -0
  14. package/dist/eventStore.d.ts +12 -0
  15. package/dist/eventStore.d.ts.map +1 -1
  16. package/dist/eventStore.js +43 -5
  17. package/dist/eventStore.js.map +1 -1
  18. package/dist/hiddenTools.d.ts +42 -0
  19. package/dist/hiddenTools.d.ts.map +1 -0
  20. package/dist/hiddenTools.js +53 -0
  21. package/dist/hiddenTools.js.map +1 -0
  22. package/dist/index.d.ts +15 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/mesh.d.ts +82 -0
  27. package/dist/mesh.d.ts.map +1 -0
  28. package/dist/mesh.js +141 -0
  29. package/dist/mesh.js.map +1 -0
  30. package/dist/middleware.d.ts +13 -0
  31. package/dist/middleware.d.ts.map +1 -1
  32. package/dist/middleware.js +27 -3
  33. package/dist/middleware.js.map +1 -1
  34. package/dist/proxyContext.d.ts +65 -5
  35. package/dist/proxyContext.d.ts.map +1 -1
  36. package/dist/proxyContext.js +39 -4
  37. package/dist/proxyContext.js.map +1 -1
  38. package/dist/rejection.d.ts +7 -1
  39. package/dist/rejection.d.ts.map +1 -1
  40. package/dist/rejection.js +24 -1
  41. package/dist/rejection.js.map +1 -1
  42. package/dist/sanitizeCatalog.d.ts +37 -0
  43. package/dist/sanitizeCatalog.d.ts.map +1 -0
  44. package/dist/sanitizeCatalog.js +92 -0
  45. package/dist/sanitizeCatalog.js.map +1 -0
  46. package/dist/sessionRegistry.d.ts +79 -0
  47. package/dist/sessionRegistry.d.ts.map +1 -0
  48. package/dist/sessionRegistry.js +83 -0
  49. package/dist/sessionRegistry.js.map +1 -0
  50. package/dist/telemetry.d.ts +29 -1
  51. package/dist/telemetry.d.ts.map +1 -1
  52. package/dist/testing.d.ts +18 -2
  53. package/dist/testing.d.ts.map +1 -1
  54. package/dist/testing.js +30 -8
  55. package/dist/testing.js.map +1 -1
  56. package/dist/version.d.ts +7 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +7 -0
  59. package/dist/version.js.map +1 -0
  60. package/package.json +22 -8
  61. package/src/__tests__/_helpers.ts +64 -0
  62. package/src/__tests__/backendClient.test.ts +198 -0
  63. package/src/__tests__/core.delegation.test.ts +964 -0
  64. package/src/__tests__/core.edge.test.ts +314 -0
  65. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  66. package/src/__tests__/core.localTools.test.ts +314 -0
  67. package/src/__tests__/core.mesh.test.ts +1182 -0
  68. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  69. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  70. package/src/__tests__/core.test.ts +1105 -0
  71. package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/hasToolContent.test.ts +63 -0
  74. package/src/__tests__/mapToolResult.test.ts +101 -0
  75. package/src/__tests__/middleware.edge.test.ts +126 -0
  76. package/src/__tests__/middleware.test.ts +233 -0
  77. package/src/__tests__/proxyContext.test.ts +140 -0
  78. package/src/__tests__/sanitizeCatalog.test.ts +380 -0
  79. package/src/__tests__/startHttpProxy.lifecycle.test.ts +675 -0
  80. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  81. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  82. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  83. package/src/__tests__/startHttpProxy.test.ts +1065 -0
  84. package/src/__tests__/testing.test.ts +250 -0
  85. package/src/__tests__/version.test.ts +12 -0
  86. package/src/backendClient.ts +83 -0
  87. package/src/core.ts +2129 -0
  88. package/src/delegation.ts +224 -0
  89. package/src/eventStore.ts +106 -0
  90. package/src/hiddenTools.ts +84 -0
  91. package/src/identity.ts +15 -0
  92. package/src/index.ts +63 -0
  93. package/src/mesh.ts +204 -0
  94. package/src/middleware.ts +101 -0
  95. package/src/proxyContext.ts +108 -0
  96. package/src/rejection.ts +62 -0
  97. package/src/sanitizeCatalog.ts +126 -0
  98. package/src/sessionRegistry.ts +142 -0
  99. package/src/telemetry.ts +46 -0
  100. package/src/testing.ts +174 -0
  101. package/src/version.ts +6 -0
@@ -0,0 +1,409 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import type * as http from 'node:http';
3
+ import type { InitializeRequestParams } from '@modelcontextprotocol/sdk/types.js';
4
+ import { startHttpProxy, type HttpProxyOptions } from '../core.js';
5
+ import { createInMemoryEventStore } from '../eventStore.js';
6
+ import type { SessionRecord, SessionRegistry } from '../sessionRegistry.js';
7
+ import type { Identity } from '../identity.js';
8
+ import {
9
+ makeMockBackend,
10
+ getPort,
11
+ closeServer,
12
+ postOnFreshConnection,
13
+ } from './_helpers.js';
14
+
15
+ const PROTOCOL_VERSION = '2025-11-25';
16
+
17
+ const MCP_HEADERS = {
18
+ 'Content-Type': 'application/json',
19
+ Accept: 'application/json, text/event-stream',
20
+ 'mcp-protocol-version': PROTOCOL_VERSION,
21
+ };
22
+
23
+ const INIT_PARAMS: InitializeRequestParams = {
24
+ protocolVersion: PROTOCOL_VERSION,
25
+ capabilities: { roots: { listChanged: true } },
26
+ clientInfo: { name: 'registry-test', version: '0.0.1' },
27
+ };
28
+
29
+ const LIST_TOOLS = JSON.stringify({
30
+ jsonrpc: '2.0',
31
+ id: 2,
32
+ method: 'tools/list',
33
+ params: {},
34
+ });
35
+
36
+ const IDENTITY: Identity = {
37
+ sub: 'alice',
38
+ type: 'human',
39
+ roles: ['reader'],
40
+ claims: { dept: 'ops', nested: { ok: true } },
41
+ resolvedAt: '2026-01-01T00:00:00.000Z',
42
+ source: 'custom',
43
+ };
44
+
45
+ /** Map-backed registry with call counters, the shape a real adapter fills. */
46
+ function fakeRegistry(): SessionRegistry & {
47
+ records: Map<string, SessionRecord>;
48
+ gets: number;
49
+ } {
50
+ const records = new Map<string, SessionRecord>();
51
+ return {
52
+ records,
53
+ gets: 0,
54
+ async set(id, record) {
55
+ records.set(id, record);
56
+ },
57
+ async get(id) {
58
+ this.gets += 1;
59
+ return records.get(id);
60
+ },
61
+ async delete(id) {
62
+ records.delete(id);
63
+ },
64
+ };
65
+ }
66
+
67
+ async function initSession(
68
+ baseUrl: string,
69
+ ): Promise<{ sessionId: string; eventId: string }> {
70
+ const res = await fetch(`${baseUrl}/mcp`, {
71
+ method: 'POST',
72
+ headers: MCP_HEADERS,
73
+ body: JSON.stringify({
74
+ jsonrpc: '2.0',
75
+ id: 1,
76
+ method: 'initialize',
77
+ params: INIT_PARAMS,
78
+ }),
79
+ });
80
+ expect(res.status).toBe(200);
81
+ const eventId = /^id: (.+)$/m.exec(await res.text())?.[1];
82
+ return { sessionId: res.headers.get('mcp-session-id')!, eventId: eventId! };
83
+ }
84
+
85
+ const servers: http.Server[] = [];
86
+ afterEach(async () => {
87
+ await Promise.all(servers.splice(0).map(closeServer));
88
+ vi.useRealTimers();
89
+ });
90
+
91
+ async function start(
92
+ httpOptions: HttpProxyOptions,
93
+ ): Promise<{ server: http.Server; baseUrl: string }> {
94
+ const server = await startHttpProxy(
95
+ makeMockBackend(),
96
+ { name: 'test-server' },
97
+ { port: 0, path: '/mcp', ...httpOptions },
98
+ );
99
+ servers.push(server);
100
+ return { server, baseUrl: `http://localhost:${getPort(server)}` };
101
+ }
102
+
103
+ const listTools = (baseUrl: string, sessionId: string): Promise<number> =>
104
+ postOnFreshConnection(
105
+ `${baseUrl}/mcp`,
106
+ { ...MCP_HEADERS, 'mcp-session-id': sessionId },
107
+ LIST_TOOLS,
108
+ );
109
+
110
+ describe('startHttpProxy() shared session registry (#155)', () => {
111
+ it('records the initialize params, identity and deadline of a new session', async () => {
112
+ const registry = fakeRegistry();
113
+ const before = Date.now();
114
+ const { baseUrl } = await start({
115
+ sessionRegistry: registry,
116
+ sessionTtlMs: 60_000,
117
+ resolveIdentity: () => IDENTITY,
118
+ });
119
+ const { sessionId } = await initSession(baseUrl);
120
+
121
+ const record = registry.records.get(sessionId);
122
+ expect(record?.initialize).toEqual(INIT_PARAMS);
123
+ expect(record?.identity).toEqual(IDENTITY);
124
+ expect(record?.expiresAt).toBeGreaterThanOrEqual(before + 60_000);
125
+ expect(record?.expiresAt).toBeLessThanOrEqual(Date.now() + 60_000);
126
+ });
127
+
128
+ it('omits the deadline when sessionTtlMs is Infinity', async () => {
129
+ const registry = fakeRegistry();
130
+ const { baseUrl } = await start({
131
+ sessionRegistry: registry,
132
+ sessionTtlMs: Infinity,
133
+ });
134
+ const { sessionId } = await initSession(baseUrl);
135
+ expect(registry.records.get(sessionId)).not.toHaveProperty('expiresAt');
136
+ expect(registry.records.get(sessionId)).not.toHaveProperty('identity');
137
+ });
138
+
139
+ it('does not answer the initialize until the record is persisted', async () => {
140
+ let release: (() => void) | undefined;
141
+ const registry = fakeRegistry();
142
+ registry.set = () =>
143
+ new Promise<void>((resolve) => {
144
+ release = resolve;
145
+ });
146
+ const { baseUrl } = await start({ sessionRegistry: registry });
147
+
148
+ const pending = fetch(`${baseUrl}/mcp`, {
149
+ method: 'POST',
150
+ headers: MCP_HEADERS,
151
+ body: JSON.stringify({
152
+ jsonrpc: '2.0',
153
+ id: 1,
154
+ method: 'initialize',
155
+ params: INIT_PARAMS,
156
+ }),
157
+ });
158
+ const settled = await Promise.race([
159
+ pending.then(() => true),
160
+ new Promise<false>((r) => setTimeout(() => r(false), 100)),
161
+ ]);
162
+ expect(settled).toBe(false);
163
+ expect(release).toBeDefined();
164
+ release!();
165
+ const res = await pending;
166
+ expect(res.status).toBe(200);
167
+ expect(res.headers.get('mcp-session-id')).toBeTruthy();
168
+ });
169
+
170
+ it('resumes a session created by a previous process, replay history included', async () => {
171
+ const registry = fakeRegistry();
172
+ const eventStore = createInMemoryEventStore();
173
+ const closedOnA: string[] = [];
174
+ const closedOnB: string[] = [];
175
+
176
+ const a = await start({
177
+ sessionRegistry: registry,
178
+ eventStore,
179
+ resolveIdentity: () => IDENTITY,
180
+ onSessionClosed: (id) => closedOnA.push(id),
181
+ });
182
+ const { sessionId, eventId } = await initSession(a.baseUrl);
183
+ await closeServer(a.server);
184
+ servers.splice(servers.indexOf(a.server), 1);
185
+ // Shutdown flushes locally but keeps the shared record.
186
+ expect(closedOnA).toEqual([sessionId]);
187
+ expect(registry.records.has(sessionId)).toBe(true);
188
+
189
+ const seen: { sessionId: string; identity?: Identity }[] = [];
190
+ const b = await start({
191
+ sessionRegistry: registry,
192
+ eventStore,
193
+ onSessionClosed: (id) => closedOnB.push(id),
194
+ validateSession: (_req, session) => {
195
+ seen.push(session);
196
+ return true;
197
+ },
198
+ });
199
+ expect(await listTools(b.baseUrl, sessionId)).toBe(200);
200
+ // Identity travels with the record; it is not re-resolved.
201
+ expect(seen[0]).toEqual({ sessionId, identity: IDENTITY });
202
+
203
+ const controller = new AbortController();
204
+ const replay = await fetch(`${b.baseUrl}/mcp`, {
205
+ method: 'GET',
206
+ headers: {
207
+ Accept: 'text/event-stream',
208
+ 'mcp-session-id': sessionId,
209
+ 'mcp-protocol-version': PROTOCOL_VERSION,
210
+ 'last-event-id': eventId,
211
+ },
212
+ signal: controller.signal,
213
+ });
214
+ controller.abort();
215
+ expect(replay.status).toBe(200);
216
+
217
+ await closeServer(b.server);
218
+ servers.splice(servers.indexOf(b.server), 1);
219
+ expect(closedOnB).toEqual([sessionId]);
220
+ });
221
+
222
+ it('honours the remaining lifetime of a resumed session, not its own TTL', async () => {
223
+ vi.useFakeTimers();
224
+ const registry = fakeRegistry();
225
+ registry.records.set('s-remaining', {
226
+ initialize: INIT_PARAMS,
227
+ expiresAt: Date.now() + 1000,
228
+ });
229
+ const closed: string[] = [];
230
+ const { baseUrl } = await start({
231
+ sessionRegistry: registry,
232
+ sessionTtlMs: 60_000,
233
+ onSessionClosed: (id) => closed.push(id),
234
+ });
235
+
236
+ expect(await listTools(baseUrl, 's-remaining')).toBe(200);
237
+ await vi.advanceTimersByTimeAsync(999);
238
+ expect(closed).toEqual([]);
239
+ await vi.advanceTimersByTimeAsync(2);
240
+ expect(closed).toEqual(['s-remaining']);
241
+ // Expiry is final everywhere, so the record goes too.
242
+ expect(registry.records.has('s-remaining')).toBe(false);
243
+ expect(await listTools(baseUrl, 's-remaining')).toBe(404);
244
+ });
245
+
246
+ it('treats an expired record as unknown', async () => {
247
+ const registry = fakeRegistry();
248
+ registry.records.set('s-expired', {
249
+ initialize: INIT_PARAMS,
250
+ expiresAt: Date.now() - 1,
251
+ });
252
+ const { baseUrl } = await start({ sessionRegistry: registry });
253
+ expect(await listTools(baseUrl, 's-expired')).toBe(404);
254
+ expect(await listTools(baseUrl, 's-unknown')).toBe(404);
255
+ });
256
+
257
+ it('forgets the record on client DELETE', async () => {
258
+ const registry = fakeRegistry();
259
+ const { baseUrl } = await start({ sessionRegistry: registry });
260
+ const { sessionId } = await initSession(baseUrl);
261
+ const res = await fetch(`${baseUrl}/mcp`, {
262
+ method: 'DELETE',
263
+ headers: { 'mcp-session-id': sessionId },
264
+ });
265
+ expect(res.status).toBe(200);
266
+ expect(registry.records.has(sessionId)).toBe(false);
267
+ });
268
+
269
+ it('forgets the record on TTL expiry and reports a failing delete', async () => {
270
+ vi.useFakeTimers();
271
+ const registry = fakeRegistry();
272
+ registry.delete = async () => {
273
+ throw new Error('registry down');
274
+ };
275
+ const errors: unknown[] = [];
276
+ const closed: string[] = [];
277
+ const { baseUrl } = await start({
278
+ sessionRegistry: registry,
279
+ sessionTtlMs: 1000,
280
+ onError: (err) => errors.push(err),
281
+ onSessionClosed: (id) => closed.push(id),
282
+ });
283
+ const { sessionId } = await initSession(baseUrl);
284
+ await vi.advanceTimersByTimeAsync(1001);
285
+ // Teardown still completes: the hook fires and the error is reported.
286
+ expect(closed).toEqual([sessionId]);
287
+ expect((errors[0] as Error).message).toBe('registry down');
288
+ });
289
+
290
+ it('coalesces concurrent requests for one unknown session into one resume', async () => {
291
+ const registry = fakeRegistry();
292
+ registry.records.set('s-shared', { initialize: INIT_PARAMS });
293
+ const closed: string[] = [];
294
+ const { server, baseUrl } = await start({
295
+ sessionRegistry: registry,
296
+ onSessionClosed: (id) => closed.push(id),
297
+ });
298
+ const statuses = await Promise.all(
299
+ Array.from({ length: 5 }, () => listTools(baseUrl, 's-shared')),
300
+ );
301
+ expect(statuses).toEqual([200, 200, 200, 200, 200]);
302
+ expect(registry.gets).toBe(1);
303
+
304
+ await closeServer(server);
305
+ servers.splice(servers.indexOf(server), 1);
306
+ expect(closed).toEqual(['s-shared']);
307
+ });
308
+
309
+ it('counts a resumed session against maxSessions', async () => {
310
+ const registry = fakeRegistry();
311
+ registry.records.set('s-extra', { initialize: INIT_PARAMS });
312
+ const { baseUrl } = await start({
313
+ sessionRegistry: registry,
314
+ maxSessions: 1,
315
+ });
316
+ await initSession(baseUrl);
317
+ expect(await listTools(baseUrl, 's-extra')).toBe(503);
318
+ // Nothing was admitted, so the record is untouched for another instance.
319
+ expect(registry.records.has('s-extra')).toBe(true);
320
+ });
321
+
322
+ it('fails the initialize and leaks nothing when the record cannot be persisted', async () => {
323
+ const registry = fakeRegistry();
324
+ registry.set = async () => {
325
+ throw new Error('registry down');
326
+ };
327
+ const errors: unknown[] = [];
328
+ const closed: string[] = [];
329
+ const { server, baseUrl } = await start({
330
+ sessionRegistry: registry,
331
+ onError: (err) => errors.push(err),
332
+ onSessionClosed: (id) => closed.push(id),
333
+ });
334
+ const res = await fetch(`${baseUrl}/mcp`, {
335
+ method: 'POST',
336
+ headers: MCP_HEADERS,
337
+ body: JSON.stringify({
338
+ jsonrpc: '2.0',
339
+ id: 1,
340
+ method: 'initialize',
341
+ params: INIT_PARAMS,
342
+ }),
343
+ });
344
+ // The SDK owns the response once its hook throws; the real cause goes
345
+ // to onError and the client sees a message that names no backing store.
346
+ expect(res.status).toBe(400);
347
+ expect(res.headers.get('mcp-session-id')).toBeNull();
348
+ const body = await res.text();
349
+ expect(body).toContain('session registry unavailable');
350
+ expect(body).not.toContain('registry down');
351
+ expect((errors[0] as Error).message).toBe('registry down');
352
+
353
+ await closeServer(server);
354
+ servers.splice(servers.indexOf(server), 1);
355
+ expect(closed).toEqual([]);
356
+ });
357
+
358
+ it('answers 500 when the registry lookup itself fails', async () => {
359
+ const registry = fakeRegistry();
360
+ registry.get = async () => {
361
+ throw new Error('registry down');
362
+ };
363
+ const errors: unknown[] = [];
364
+ const { baseUrl } = await start({
365
+ sessionRegistry: registry,
366
+ onError: (err) => errors.push(err),
367
+ });
368
+ expect(await listTools(baseUrl, 's-any')).toBe(500);
369
+ expect((errors[0] as Error).message).toBe('registry down');
370
+ });
371
+
372
+ it('runs validateSession against the stored identity on a resumed request', async () => {
373
+ const registry = fakeRegistry();
374
+ registry.records.set('s-alice', {
375
+ initialize: INIT_PARAMS,
376
+ identity: IDENTITY,
377
+ });
378
+ const seen: (Identity | undefined)[] = [];
379
+ const { baseUrl } = await start({
380
+ sessionRegistry: registry,
381
+ validateSession: (_req, session) => {
382
+ seen.push(session.identity);
383
+ return session.identity?.sub === 'bob';
384
+ },
385
+ });
386
+ expect(await listTools(baseUrl, 's-alice')).toBe(401);
387
+ expect(seen).toEqual([IDENTITY]);
388
+ });
389
+
390
+ it("relays the SDK's rejection when the replayed initialize fails its checks", async () => {
391
+ const registry = fakeRegistry();
392
+ registry.records.set('s-host', { initialize: INIT_PARAMS });
393
+ const closed: string[] = [];
394
+ const { server, baseUrl } = await start({
395
+ sessionRegistry: registry,
396
+ enableDnsRebindingProtection: true,
397
+ allowedHosts: ['example.com'],
398
+ onSessionClosed: (id) => closed.push(id),
399
+ });
400
+ // The real request would fail the same Host check, so the client sees
401
+ // the SDK's own answer rather than a 404 or a 500.
402
+ expect(await listTools(baseUrl, 's-host')).toBe(403);
403
+ expect(registry.records.has('s-host')).toBe(true);
404
+
405
+ await closeServer(server);
406
+ servers.splice(servers.indexOf(server), 1);
407
+ expect(closed).toEqual([]);
408
+ });
409
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { startHttpProxy } from '../core.js';
4
+ import { createInMemoryEventStore } from '../eventStore.js';
5
+ import { makeMockBackend, getPort, closeServer } from './_helpers.js';
6
+
7
+ // Priming events (the first `id:` on a resumable stream) are only sent to
8
+ // clients that negotiated a protocol version with empty-data SSE support.
9
+ const PROTOCOL_VERSION = '2025-11-25';
10
+
11
+ const MCP_HEADERS = {
12
+ 'Content-Type': 'application/json',
13
+ Accept: 'application/json, text/event-stream',
14
+ };
15
+
16
+ /** Initializes a session and returns the first event id on its response stream. */
17
+ async function initSession(
18
+ baseUrl: string,
19
+ ): Promise<{ sessionId: string; eventId: string }> {
20
+ const res = await fetch(`${baseUrl}/mcp`, {
21
+ method: 'POST',
22
+ headers: MCP_HEADERS,
23
+ body: JSON.stringify({
24
+ jsonrpc: '2.0',
25
+ id: 1,
26
+ method: 'initialize',
27
+ params: {
28
+ protocolVersion: PROTOCOL_VERSION,
29
+ capabilities: {},
30
+ clientInfo: { name: 'test', version: '0.0.1' },
31
+ },
32
+ }),
33
+ });
34
+ const body = await res.text();
35
+ const eventId = /^id: (.+)$/m.exec(body)?.[1];
36
+ expect(eventId).toBeTruthy();
37
+ return { sessionId: res.headers.get('mcp-session-id')!, eventId: eventId! };
38
+ }
39
+
40
+ async function resumeStandaloneStream(
41
+ baseUrl: string,
42
+ sessionId: string,
43
+ lastEventId: string,
44
+ ): Promise<number> {
45
+ const controller = new AbortController();
46
+ const res = await fetch(`${baseUrl}/mcp`, {
47
+ method: 'GET',
48
+ headers: {
49
+ Accept: 'text/event-stream',
50
+ 'mcp-session-id': sessionId,
51
+ 'mcp-protocol-version': PROTOCOL_VERSION,
52
+ 'last-event-id': lastEventId,
53
+ },
54
+ signal: controller.signal,
55
+ });
56
+ controller.abort();
57
+ return res.status;
58
+ }
59
+
60
+ describe('startHttpProxy() SSE replay is scoped per session (#154)', () => {
61
+ it('namespaces stream ids by session id before they reach the store', async () => {
62
+ const streamIds: string[] = [];
63
+ const inner = createInMemoryEventStore();
64
+ const eventStore: EventStore = {
65
+ ...inner,
66
+ storeEvent(streamId, message) {
67
+ streamIds.push(streamId);
68
+ return inner.storeEvent(streamId, message);
69
+ },
70
+ };
71
+ const server = await startHttpProxy(
72
+ makeMockBackend(),
73
+ { name: 'test-server' },
74
+ { port: 0, path: '/mcp', eventStore },
75
+ );
76
+ const baseUrl = `http://localhost:${getPort(server)}`;
77
+ try {
78
+ const a = await initSession(baseUrl);
79
+ const b = await initSession(baseUrl);
80
+ expect(streamIds.length).toBeGreaterThan(0);
81
+ expect(
82
+ streamIds.every(
83
+ (id) =>
84
+ id.startsWith(`${a.sessionId}:`) ||
85
+ id.startsWith(`${b.sessionId}:`),
86
+ ),
87
+ ).toBe(true);
88
+ expect(streamIds.some((id) => id.startsWith(`${b.sessionId}:`))).toBe(
89
+ true,
90
+ );
91
+ } finally {
92
+ await closeServer(server);
93
+ }
94
+ });
95
+
96
+ it("rejects another session's Last-Event-ID and resumes its own", async () => {
97
+ const server = await startHttpProxy(
98
+ makeMockBackend(),
99
+ { name: 'test-server' },
100
+ { port: 0, path: '/mcp' },
101
+ );
102
+ const baseUrl = `http://localhost:${getPort(server)}`;
103
+ try {
104
+ const a = await initSession(baseUrl);
105
+ const b = await initSession(baseUrl);
106
+ expect(
107
+ await resumeStandaloneStream(baseUrl, b.sessionId, a.eventId),
108
+ ).toBe(400);
109
+ expect(
110
+ await resumeStandaloneStream(baseUrl, a.sessionId, a.eventId),
111
+ ).toBe(200);
112
+ } finally {
113
+ await closeServer(server);
114
+ }
115
+ });
116
+ });