@harness-fe/mcp-server 3.0.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.
- package/README.md +64 -0
- package/dist/cli.js +19 -17
- package/dist/daemon.d.ts +19 -0
- package/dist/daemon.js +19 -8
- package/dist/mcp.js +120 -11
- package/dist/store/types.d.ts +1 -1
- package/dist/visitorTimeline.d.ts +24 -0
- package/dist/visitorTimeline.js +68 -0
- package/package.json +3 -3
- package/src/cli.ts +19 -14
- package/src/daemon.ts +39 -8
- package/src/mcp.ts +186 -11
- package/src/mcpHttp.test.ts +102 -0
- package/src/mcpLayer.e2e.test.ts +235 -0
- package/src/newCapabilities.e2e.test.ts +303 -0
- package/src/store/types.ts +1 -0
- package/src/visitorTimeline.test.ts +197 -0
- package/src/visitorTimeline.ts +89 -0
package/src/daemon.ts
CHANGED
|
@@ -47,8 +47,19 @@ export interface DaemonOptions {
|
|
|
47
47
|
* skipped — there is exactly one auth pipeline. Synchronous because the
|
|
48
48
|
* WS upgrade handshake completes inline; async auth should be cached in
|
|
49
49
|
* a cookie by the host's own middleware and read back here.
|
|
50
|
+
*
|
|
51
|
+
* Mutually exclusive with `token`. If both are provided, `authorize`
|
|
52
|
+
* wins and `token` is ignored.
|
|
50
53
|
*/
|
|
51
54
|
authorize?: (req: IncomingMessage) => boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Static token; auth requires `Authorization: Bearer <token>`, the
|
|
57
|
+
* `harness_fe_token` cookie, `?token=<token>`, or the matching WS
|
|
58
|
+
* subprotocol. Use this when you want the daemon's built-in auth and
|
|
59
|
+
* have no reason to plug in custom logic — the CLI's `--token` flag
|
|
60
|
+
* flows through here. Mutually exclusive with `authorize`.
|
|
61
|
+
*/
|
|
62
|
+
token?: string;
|
|
52
63
|
/**
|
|
53
64
|
* IStore implementation. Omit for the default JSONL store at `dataDir`.
|
|
54
65
|
* Pass `null` to disable session/event persistence entirely.
|
|
@@ -80,6 +91,14 @@ export interface DaemonOptions {
|
|
|
80
91
|
* Claude Code, Cursor, and the MCP spec default expect).
|
|
81
92
|
*/
|
|
82
93
|
mcpStateful?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Whether to mount the MCP HTTP Streamable transport at `mcpPath`.
|
|
96
|
+
* Default `true`. Set to `false` if the host only needs the WS bridge
|
|
97
|
+
* (and wires up MCP over stdio externally — that's how the CLI's
|
|
98
|
+
* stdio mode boots the daemon through this factory without also
|
|
99
|
+
* exposing an HTTP MCP endpoint).
|
|
100
|
+
*/
|
|
101
|
+
mcpHttp?: boolean;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
export interface DaemonHandle {
|
|
@@ -103,6 +122,16 @@ export interface DaemonHandle {
|
|
|
103
122
|
|
|
104
123
|
export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
|
|
105
124
|
const mcpPath = opts.mcpPath ?? '/mcp';
|
|
125
|
+
const mountMcpHttp = opts.mcpHttp ?? true;
|
|
126
|
+
|
|
127
|
+
// Resolve auth: authorize wins if both are set. `undefined` here means
|
|
128
|
+
// no auth at all (loopback dev default). One pipeline — Bridge sees
|
|
129
|
+
// exactly one of `{ authorize }`, `{ token }`, or nothing.
|
|
130
|
+
const auth = opts.authorize
|
|
131
|
+
? { authorize: opts.authorize }
|
|
132
|
+
: opts.token
|
|
133
|
+
? { token: opts.token }
|
|
134
|
+
: undefined;
|
|
106
135
|
|
|
107
136
|
const bridge = new Bridge({
|
|
108
137
|
port: opts.port,
|
|
@@ -113,7 +142,7 @@ export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
|
|
|
113
142
|
memoryStore: opts.memoryStore,
|
|
114
143
|
dataDir: opts.dataDir,
|
|
115
144
|
label: opts.label,
|
|
116
|
-
auth
|
|
145
|
+
auth,
|
|
117
146
|
});
|
|
118
147
|
|
|
119
148
|
let mcpHandle: Awaited<ReturnType<typeof startMcpHttpServer>> | undefined;
|
|
@@ -128,13 +157,15 @@ export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
|
|
|
128
157
|
if (started) return;
|
|
129
158
|
started = true;
|
|
130
159
|
await bridge.start();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
if (mountMcpHttp) {
|
|
161
|
+
// Forward eventStore as-is so `null` opts out of resumability and
|
|
162
|
+
// `undefined` falls through to the default MemoryEventStore.
|
|
163
|
+
mcpHandle = await startMcpHttpServer(bridge, {
|
|
164
|
+
path: mcpPath,
|
|
165
|
+
stateful: opts.mcpStateful,
|
|
166
|
+
eventStore: opts.eventStore,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
138
169
|
},
|
|
139
170
|
|
|
140
171
|
async stop() {
|
package/src/mcp.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { IBridge } from './bridge.js';
|
|
|
28
28
|
import type { Bridge } from './bridge.js';
|
|
29
29
|
import { RemoteBridge } from './remoteBridge.js';
|
|
30
30
|
import type { IStore, IMemoryStore } from './store/index.js';
|
|
31
|
+
import { buildVisitorTimeline } from './visitorTimeline.js';
|
|
31
32
|
import { createReplayExport } from './replayCreate.js';
|
|
32
33
|
import { openBrowser } from './openBrowser.js';
|
|
33
34
|
import { buildDashboardUrl } from './dashboardUrl.js';
|
|
@@ -308,17 +309,27 @@ function registerTools(server: McpServer, bridge: IBridge): void {
|
|
|
308
309
|
},
|
|
309
310
|
);
|
|
310
311
|
|
|
312
|
+
const filterParam = z.string().optional().describe('Substring or regex (see `match`). Filters entries by their serialized payload before return.');
|
|
313
|
+
const matchParam = z.enum(['contains', 'regex']).optional().describe('How to interpret `filter`. Default: contains (case-insensitive). regex is case-insensitive too.');
|
|
314
|
+
|
|
311
315
|
server.registerTool(
|
|
312
316
|
COMMAND.CONSOLE_TAIL,
|
|
313
317
|
{
|
|
314
|
-
description: 'Return the last N console entries from the page.',
|
|
318
|
+
description: 'Return the last N console entries from the page. Pass `filter` for substring/regex match against {level, args}; `level` for an exact match. Buffer is in-memory and cleared on navigate — use `session.tail` (type=["log"]) for cross-navigate history.',
|
|
315
319
|
inputSchema: {
|
|
316
320
|
n: z.number().int().positive().default(20).optional(),
|
|
321
|
+
filter: filterParam,
|
|
322
|
+
match: matchParam,
|
|
323
|
+
level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
|
|
317
324
|
tabId: tabIdParam,
|
|
318
325
|
},
|
|
319
326
|
},
|
|
320
|
-
async ({ n, tabId }) => {
|
|
321
|
-
const out = await bridge.sendCommand(
|
|
327
|
+
async ({ n, filter, match, level, tabId }) => {
|
|
328
|
+
const out = await bridge.sendCommand(
|
|
329
|
+
COMMAND.CONSOLE_TAIL,
|
|
330
|
+
{ n: n ?? 20, filter, match, level },
|
|
331
|
+
{ tabId },
|
|
332
|
+
);
|
|
322
333
|
return ok(out);
|
|
323
334
|
},
|
|
324
335
|
);
|
|
@@ -326,17 +337,22 @@ function registerTools(server: McpServer, bridge: IBridge): void {
|
|
|
326
337
|
server.registerTool(
|
|
327
338
|
COMMAND.NETWORK_TAIL,
|
|
328
339
|
{
|
|
329
|
-
description: 'Return the last N network requests captured by the runtime client.',
|
|
340
|
+
description: 'Return the last N network requests captured by the runtime client. Each entry has phase=req|res keyed by `id`, and (for requests) an `initiator.stack` so you can see which code issued the call. Pass `filter` (against {url, method, body}), or narrow via `urlContains` / `method` / `statusCode`. Buffer is in-memory and cleared on navigate — use `session.tail` (type=["req","res"]) for cross-navigate history.',
|
|
330
341
|
inputSchema: {
|
|
331
342
|
n: z.number().int().positive().default(20).optional(),
|
|
332
343
|
includeBody: z.boolean().optional(),
|
|
344
|
+
filter: filterParam,
|
|
345
|
+
match: matchParam,
|
|
346
|
+
urlContains: z.string().optional().describe('Substring filter on url (case-sensitive).'),
|
|
347
|
+
method: z.string().optional().describe('Exact HTTP method match (e.g. "POST"). Case-insensitive.'),
|
|
348
|
+
statusCode: z.number().int().optional().describe('Exact status code match (response entries only).'),
|
|
333
349
|
tabId: tabIdParam,
|
|
334
350
|
},
|
|
335
351
|
},
|
|
336
|
-
async ({ n, includeBody, tabId }) => {
|
|
352
|
+
async ({ n, includeBody, filter, match, urlContains, method, statusCode, tabId }) => {
|
|
337
353
|
const out = await bridge.sendCommand(
|
|
338
354
|
COMMAND.NETWORK_TAIL,
|
|
339
|
-
{ n: n ?? 20, includeBody: includeBody ?? false },
|
|
355
|
+
{ n: n ?? 20, includeBody: includeBody ?? false, filter, match, urlContains, method, statusCode },
|
|
340
356
|
{ tabId },
|
|
341
357
|
);
|
|
342
358
|
return ok(out);
|
|
@@ -346,14 +362,145 @@ function registerTools(server: McpServer, bridge: IBridge): void {
|
|
|
346
362
|
server.registerTool(
|
|
347
363
|
COMMAND.ERRORS_TAIL,
|
|
348
364
|
{
|
|
349
|
-
description: 'Return the last N JavaScript errors captured by the runtime client.',
|
|
365
|
+
description: 'Return the last N JavaScript errors captured by the runtime client. Pass `filter` for substring/regex match against {message, stack, source}. Buffer is in-memory and cleared on navigate — use `session.tail` (type=["err"]) for cross-navigate history.',
|
|
350
366
|
inputSchema: {
|
|
351
367
|
n: z.number().int().positive().default(20).optional(),
|
|
368
|
+
filter: filterParam,
|
|
369
|
+
match: matchParam,
|
|
370
|
+
tabId: tabIdParam,
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
async ({ n, filter, match, tabId }) => {
|
|
374
|
+
const out = await bridge.sendCommand(
|
|
375
|
+
COMMAND.ERRORS_TAIL,
|
|
376
|
+
{ n: n ?? 20, filter, match },
|
|
377
|
+
{ tabId },
|
|
378
|
+
);
|
|
379
|
+
return ok(out);
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
server.registerTool(
|
|
384
|
+
COMMAND.WS_TAIL,
|
|
385
|
+
{
|
|
386
|
+
description:
|
|
387
|
+
'Return the last N WebSocket frames captured by the runtime client. Each entry has phase=open|send|recv|close, a stable id per connection, payload (text/JSON when possible, [binary Nb] for buffers), and initiator.stack on open/send so you can see which code opened the connection or sent the frame. Pass `filter` for substring/regex match against {url, payload, reason}; `phase` for an exact match. Buffer is in-memory and cleared on navigate — use `session.tail` (type=["ws"]) for cross-navigate history.',
|
|
388
|
+
inputSchema: {
|
|
389
|
+
n: z.number().int().positive().default(20).optional(),
|
|
390
|
+
filter: filterParam,
|
|
391
|
+
match: matchParam,
|
|
392
|
+
phase: z.enum(['open', 'send', 'recv', 'close']).optional(),
|
|
393
|
+
tabId: tabIdParam,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
async ({ n, filter, match, phase, tabId }) => {
|
|
397
|
+
const out = await bridge.sendCommand(
|
|
398
|
+
COMMAND.WS_TAIL,
|
|
399
|
+
{ n: n ?? 20, filter, match, phase },
|
|
400
|
+
{ tabId },
|
|
401
|
+
);
|
|
402
|
+
return ok(out);
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
server.registerTool(
|
|
407
|
+
COMMAND.NETWORK_WAIT_FOR,
|
|
408
|
+
{
|
|
409
|
+
description:
|
|
410
|
+
'Resolve when a network request matching the predicate happens (or rejects on timeout). Considers requests issued AFTER this call — pre-existing matches in the buffer do not satisfy the wait.',
|
|
411
|
+
inputSchema: {
|
|
412
|
+
urlContains: z.string().optional(),
|
|
413
|
+
urlRegex: z.string().optional().describe('Case-insensitive regex against url.'),
|
|
414
|
+
method: z.string().optional(),
|
|
415
|
+
statusCode: z.number().int().optional(),
|
|
416
|
+
timeoutMs: z.number().int().positive().default(10000).optional(),
|
|
352
417
|
tabId: tabIdParam,
|
|
353
418
|
},
|
|
354
419
|
},
|
|
355
|
-
async ({
|
|
356
|
-
const out = await bridge.sendCommand(
|
|
420
|
+
async ({ urlContains, urlRegex, method, statusCode, timeoutMs, tabId }) => {
|
|
421
|
+
const out = await bridge.sendCommand(
|
|
422
|
+
COMMAND.NETWORK_WAIT_FOR,
|
|
423
|
+
{ urlContains, urlRegex, method, statusCode, timeoutMs },
|
|
424
|
+
{ tabId },
|
|
425
|
+
);
|
|
426
|
+
return ok(out);
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
server.registerTool(
|
|
431
|
+
COMMAND.NETWORK_WAIT_FOR_IDLE,
|
|
432
|
+
{
|
|
433
|
+
description:
|
|
434
|
+
'Resolve when no new network entries arrived for `idleMs` (default 500ms) — analogous to Playwright `waitForLoadState("networkidle")`. Useful for sequencing actions after a navigation or interaction.',
|
|
435
|
+
inputSchema: {
|
|
436
|
+
idleMs: z.number().int().positive().default(500).optional(),
|
|
437
|
+
timeoutMs: z.number().int().positive().default(10000).optional(),
|
|
438
|
+
tabId: tabIdParam,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
async ({ idleMs, timeoutMs, tabId }) => {
|
|
442
|
+
const out = await bridge.sendCommand(
|
|
443
|
+
COMMAND.NETWORK_WAIT_FOR_IDLE,
|
|
444
|
+
{ idleMs, timeoutMs },
|
|
445
|
+
{ tabId },
|
|
446
|
+
);
|
|
447
|
+
return ok(out);
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
server.registerTool(
|
|
452
|
+
COMMAND.NETWORK_GET,
|
|
453
|
+
{
|
|
454
|
+
description:
|
|
455
|
+
'Return all entries (req + res) for a single network request id. Use after `network.tail` when you need the full request/response body without the truncation pressure that comes from a multi-entry response.',
|
|
456
|
+
inputSchema: {
|
|
457
|
+
reqId: z.string().describe('id field from a `network.tail` entry'),
|
|
458
|
+
tabId: tabIdParam,
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
async ({ reqId, tabId }) => {
|
|
462
|
+
const out = await bridge.sendCommand(COMMAND.NETWORK_GET, { reqId }, { tabId });
|
|
463
|
+
return ok(out);
|
|
464
|
+
},
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
server.registerTool(
|
|
468
|
+
COMMAND.WS_GET,
|
|
469
|
+
{
|
|
470
|
+
description:
|
|
471
|
+
'Return all frames (open / send / recv / close) for a single WebSocket id. Use after `ws.tail` when you need the full session of a particular connection.',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
wsId: z.string().describe('id field from a `ws.tail` entry'),
|
|
474
|
+
tabId: tabIdParam,
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
async ({ wsId, tabId }) => {
|
|
478
|
+
const out = await bridge.sendCommand(COMMAND.WS_GET, { wsId }, { tabId });
|
|
479
|
+
return ok(out);
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
server.registerTool(
|
|
484
|
+
COMMAND.STORAGE_TAIL,
|
|
485
|
+
{
|
|
486
|
+
description:
|
|
487
|
+
'Return the last N localStorage / sessionStorage / cookie mutations. Each entry has op=set|remove|clear, which=local|session|cookie, key/value, an `initiator.stack` showing who issued the write, and crossTab=true when the mutation arrived via the native storage event from another tab. Filter via `filter` (against {op, which, key, value}), or narrow with `which` / `op` / `key`. Buffer is in-memory and cleared on navigate — use `session.tail` (type=["storage"]) for cross-navigate history.',
|
|
488
|
+
inputSchema: {
|
|
489
|
+
n: z.number().int().positive().default(20).optional(),
|
|
490
|
+
filter: filterParam,
|
|
491
|
+
match: matchParam,
|
|
492
|
+
which: z.enum(['local', 'session', 'cookie']).optional(),
|
|
493
|
+
op: z.enum(['set', 'remove', 'clear']).optional(),
|
|
494
|
+
key: z.string().optional().describe('Exact key match (case-sensitive).'),
|
|
495
|
+
tabId: tabIdParam,
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
async ({ n, filter, match, which, op, key, tabId }) => {
|
|
499
|
+
const out = await bridge.sendCommand(
|
|
500
|
+
COMMAND.STORAGE_TAIL,
|
|
501
|
+
{ n: n ?? 20, filter, match, which, op, key },
|
|
502
|
+
{ tabId },
|
|
503
|
+
);
|
|
357
504
|
return ok(out);
|
|
358
505
|
},
|
|
359
506
|
);
|
|
@@ -602,7 +749,7 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
602
749
|
server.registerTool(
|
|
603
750
|
'session.tail',
|
|
604
751
|
{
|
|
605
|
-
description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
|
|
752
|
+
description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId. For cross-tab debugging within one visitor, use `visitor.timeline` to merge multiple sessions.',
|
|
606
753
|
inputSchema: {
|
|
607
754
|
sessionId: z.string(),
|
|
608
755
|
n: z.number().int().positive().default(50).optional(),
|
|
@@ -840,6 +987,34 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
840
987
|
},
|
|
841
988
|
);
|
|
842
989
|
|
|
990
|
+
server.registerTool(
|
|
991
|
+
'visitor.timeline',
|
|
992
|
+
{
|
|
993
|
+
description:
|
|
994
|
+
'Merged event timeline across all sessions for one visitor, ascending by ts. Use this for cross-tab debugging (e.g. a ws frame in tab A causing a storage write in tab B). Each event carries `sessionId` and `tab` so the source tab is visible. Pass `sessionIds` to skip auto-discovery and merge a known set.',
|
|
995
|
+
inputSchema: {
|
|
996
|
+
visitorId: z.string(),
|
|
997
|
+
since: z.number().optional().describe('Only events after this Unix ts (ms)'),
|
|
998
|
+
until: z.number().optional().describe('Only events before this Unix ts (ms)'),
|
|
999
|
+
types: z.union([z.string(), z.array(z.string())]).optional()
|
|
1000
|
+
.describe('Filter by event type(s): log, err, req, res, ws, storage, cmd, resp, ...'),
|
|
1001
|
+
tabIds: z.array(z.string()).optional()
|
|
1002
|
+
.describe('Narrow merge to specific tabIds. Default: all tabs known to this visitor.'),
|
|
1003
|
+
sessionIds: z.array(z.string()).optional()
|
|
1004
|
+
.describe('Explicit session list to merge. When set, skips visitor → session discovery.'),
|
|
1005
|
+
limit: z.number().int().positive().optional()
|
|
1006
|
+
.describe('Max events returned (newest). Default 200.'),
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
async ({ visitorId, since, until, types, tabIds, sessionIds, limit }) => {
|
|
1010
|
+
const result = buildVisitorTimeline(store, visitorId, {
|
|
1011
|
+
since, until, types, tabIds, sessionIds, limit,
|
|
1012
|
+
});
|
|
1013
|
+
if ('error' in result) return err(result.error);
|
|
1014
|
+
return ok(result);
|
|
1015
|
+
},
|
|
1016
|
+
);
|
|
1017
|
+
|
|
843
1018
|
server.registerTool(
|
|
844
1019
|
'session.recordings.list',
|
|
845
1020
|
{
|
|
@@ -1087,7 +1262,7 @@ function registerRemoteStoreTools(server: McpServer, bridge: RemoteBridge): void
|
|
|
1087
1262
|
server.registerTool(
|
|
1088
1263
|
'session.tail',
|
|
1089
1264
|
{
|
|
1090
|
-
description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
|
|
1265
|
+
description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId. For cross-tab debugging within one visitor, use `visitor.timeline` to merge multiple sessions.',
|
|
1091
1266
|
inputSchema: {
|
|
1092
1267
|
sessionId: z.string(),
|
|
1093
1268
|
n: z.number().int().positive().default(50).optional(),
|
package/src/mcpHttp.test.ts
CHANGED
|
@@ -98,4 +98,106 @@ describe('mcpHttp', () => {
|
|
|
98
98
|
// bodyless GET) means we made it past the auth layer.
|
|
99
99
|
expect(withAuth.status).not.toBe(401);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
// SSE Last-Event-ID resumption — end-to-end wiring proof.
|
|
103
|
+
//
|
|
104
|
+
// What this asserts:
|
|
105
|
+
// 1. A real MCP HTTP session goes through the wire protocol cleanly.
|
|
106
|
+
// 2. The configured `EventStore` actually sees `storeEvent` calls
|
|
107
|
+
// (the SDK is wired to persist outgoing messages through the
|
|
108
|
+
// transport — not just sitting unused).
|
|
109
|
+
// 3. When a new GET arrives with `Last-Event-ID`, the SDK invokes
|
|
110
|
+
// `replayEventsAfter` on the same store with that id (the resume
|
|
111
|
+
// path is taken; replay isn't a no-op).
|
|
112
|
+
//
|
|
113
|
+
// The actual "no dupes / no gaps" invariant is covered comprehensively
|
|
114
|
+
// by MemoryEventStore.test.ts — here we prove the transport drives it.
|
|
115
|
+
it('drives the EventStore on stream and replays after Last-Event-ID', async () => {
|
|
116
|
+
const bridge = await startBridge();
|
|
117
|
+
|
|
118
|
+
// Spy that mirrors MemoryEventStore's contract while recording calls.
|
|
119
|
+
const storeCalls: Array<{ streamId: string; eventId: string }> = [];
|
|
120
|
+
const replayCalls: Array<{ lastEventId: string }> = [];
|
|
121
|
+
const eventsByStream = new Map<
|
|
122
|
+
string,
|
|
123
|
+
Array<{ eventId: string; message: JSONRPCMessage }>
|
|
124
|
+
>();
|
|
125
|
+
let seq = 0;
|
|
126
|
+
const spy: EventStore = {
|
|
127
|
+
async storeEvent(streamId, message) {
|
|
128
|
+
seq += 1;
|
|
129
|
+
const eventId = `${streamId}_${seq}`;
|
|
130
|
+
storeCalls.push({ streamId, eventId });
|
|
131
|
+
const arr = eventsByStream.get(streamId) ?? [];
|
|
132
|
+
arr.push({ eventId, message });
|
|
133
|
+
eventsByStream.set(streamId, arr);
|
|
134
|
+
return eventId;
|
|
135
|
+
},
|
|
136
|
+
async replayEventsAfter(lastEventId, { send }) {
|
|
137
|
+
replayCalls.push({ lastEventId });
|
|
138
|
+
// Recover streamId from event id and replay everything past it.
|
|
139
|
+
const streamId = lastEventId.split('_')[0];
|
|
140
|
+
const arr = eventsByStream.get(streamId) ?? [];
|
|
141
|
+
let resuming = false;
|
|
142
|
+
for (const { eventId, message } of arr) {
|
|
143
|
+
if (resuming) await send(eventId, message);
|
|
144
|
+
if (eventId === lastEventId) resuming = true;
|
|
145
|
+
}
|
|
146
|
+
return streamId;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handle = await startMcpHttpServer(bridge, {
|
|
151
|
+
path: '/mcp',
|
|
152
|
+
eventStore: spy,
|
|
153
|
+
});
|
|
154
|
+
cleanups.push(() => handle.close());
|
|
155
|
+
const port = bridge.getBoundPort()!;
|
|
156
|
+
const url = `http://127.0.0.1:${port}/mcp`;
|
|
157
|
+
|
|
158
|
+
// 1. Initialize MCP session. The response goes back over the
|
|
159
|
+
// Streamable HTTP response stream, so the SDK persists each
|
|
160
|
+
// outgoing message through our spy store.
|
|
161
|
+
const init = await fetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'content-type': 'application/json',
|
|
165
|
+
accept: 'application/json, text/event-stream',
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
jsonrpc: '2.0',
|
|
169
|
+
id: 1,
|
|
170
|
+
method: 'initialize',
|
|
171
|
+
params: {
|
|
172
|
+
protocolVersion: '2024-11-05',
|
|
173
|
+
capabilities: {},
|
|
174
|
+
clientInfo: { name: 'mcpHttp.test', version: '0.0.0' },
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
const sessionId = init.headers.get('mcp-session-id');
|
|
179
|
+
expect(sessionId).toBeTruthy();
|
|
180
|
+
// Drain the response body so the connection can be reused.
|
|
181
|
+
await init.text();
|
|
182
|
+
|
|
183
|
+
// 2. The SDK should have persisted at least one message to the
|
|
184
|
+
// EventStore by now — the initialize response.
|
|
185
|
+
expect(storeCalls.length).toBeGreaterThan(0);
|
|
186
|
+
const firstEventId = storeCalls[0]!.eventId;
|
|
187
|
+
|
|
188
|
+
// 3. Reopen the stream with Last-Event-ID set. This is what a
|
|
189
|
+
// real client would do after a transient disconnect.
|
|
190
|
+
const resumed = await fetch(url, {
|
|
191
|
+
headers: {
|
|
192
|
+
accept: 'text/event-stream',
|
|
193
|
+
'mcp-session-id': sessionId!,
|
|
194
|
+
'last-event-id': firstEventId,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
// Status varies (200 SSE) but the salient assertion is the spy
|
|
198
|
+
// saw the replay path get hit with the same id we passed.
|
|
199
|
+
expect(replayCalls).toEqual([{ lastEventId: firstEventId }]);
|
|
200
|
+
// Drop the long-lived stream we just opened — it has no consumer.
|
|
201
|
+
await resumed.body?.cancel();
|
|
202
|
+
});
|
|
101
203
|
});
|