@harness-fe/mcp-server 3.0.1 → 3.2.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/src/cli.ts CHANGED
@@ -24,10 +24,10 @@ import {
24
24
  isLoopbackHost,
25
25
  parseWsUrl,
26
26
  } from '@harness-fe/protocol';
27
- import { Bridge, defaultDataDir, type IBridge } from './bridge.js';
27
+ import { defaultDataDir, type IBridge } from './bridge.js';
28
+ import { createDaemon, type DaemonHandle } from './daemon.js';
28
29
  import { RemoteBridge } from './remoteBridge.js';
29
30
  import { startMcpStdioServer } from './mcp.js';
30
- import { startMcpHttpServer } from './mcpHttp.js';
31
31
 
32
32
  type McpTransport = 'stdio' | 'http';
33
33
 
@@ -247,11 +247,13 @@ async function main() {
247
247
  const { active, shutdown, role } = await startBridgeOrAttach(cfg);
248
248
  printBanner(cfg, role, active.getViewerBaseUrl());
249
249
 
250
- let mcpShutdown: (() => Promise<void>) | undefined;
251
250
  if (cfg.mcpTransport === 'stdio') {
252
251
  await startMcpStdioServer(active);
253
252
  process.stderr.write('[harness-fe] MCP stdio server connected\n');
254
253
  } else {
254
+ // HTTP transport: the leader's createDaemon() call already mounted
255
+ // /mcp via mcpHttp:true. Followers fall through here with no leader
256
+ // attached, so HTTP mode is unsupported for them.
255
257
  if (role === 'follower') {
256
258
  process.stderr.write(
257
259
  '[harness-fe] --mcp-transport=http is only supported on the leader. ' +
@@ -260,16 +262,11 @@ async function main() {
260
262
  await shutdown();
261
263
  process.exit(2);
262
264
  }
263
- const handle = await startMcpHttpServer(active, { path: cfg.mcpPath });
264
- process.stderr.write(`[harness-fe] MCP http server mounted at ${handle.path}\n`);
265
- mcpShutdown = () => handle.close();
265
+ process.stderr.write(`[harness-fe] MCP http server mounted at ${cfg.mcpPath}\n`);
266
266
  }
267
267
 
268
268
  const onSignal = async () => {
269
269
  process.stderr.write('[harness-fe] shutting down\n');
270
- if (mcpShutdown) {
271
- try { await mcpShutdown(); } catch { /* swallow */ }
272
- }
273
270
  await shutdown();
274
271
  process.exit(0);
275
272
  };
@@ -280,23 +277,31 @@ async function main() {
280
277
  async function startBridgeOrAttach(
281
278
  cfg: CliConfig,
282
279
  ): Promise<{ active: IBridge; shutdown: () => Promise<void>; role: 'leader' | 'follower' }> {
283
- const bridge = new Bridge({
280
+ // Leader path: use createDaemon so there's exactly one boot path between
281
+ // the CLI and any host application that embeds the daemon. The factory
282
+ // mounts /mcp itself when mcpHttp:true, so we don't need to call
283
+ // startMcpHttpServer here.
284
+ const daemon: DaemonHandle = createDaemon({
284
285
  port: cfg.port,
285
286
  host: cfg.host,
286
287
  dataDir: cfg.dataDir,
287
288
  label: cfg.label,
288
- auth: cfg.token ? { token: cfg.token } : undefined,
289
+ token: cfg.token,
289
290
  publicHost: cfg.publicHost,
291
+ mcpHttp: cfg.mcpTransport === 'http',
292
+ mcpPath: cfg.mcpPath,
290
293
  });
291
294
  try {
292
- await bridge.start();
295
+ await daemon.start();
293
296
  return {
294
- active: bridge,
295
- shutdown: () => bridge.stop(),
297
+ active: daemon.bridge,
298
+ shutdown: () => daemon.stop(),
296
299
  role: 'leader',
297
300
  };
298
301
  } catch (err) {
299
302
  if ((err as NodeJS.ErrnoException)?.code !== 'EADDRINUSE') throw err;
303
+ // Factory's bridge.start failed on EADDRINUSE; the factory itself
304
+ // didn't mount anything else, so there's nothing further to clean up.
300
305
  }
301
306
 
302
307
  // Port already taken — attach as follower.
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: opts.authorize ? { authorize: opts.authorize } : undefined,
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
- // Forward eventStore as-is so `null` opts out of resumability and
132
- // `undefined` falls through to the default MemoryEventStore.
133
- mcpHandle = await startMcpHttpServer(bridge, {
134
- path: mcpPath,
135
- stateful: opts.mcpStateful,
136
- eventStore: opts.eventStore,
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(COMMAND.CONSOLE_TAIL, { n: n ?? 20 }, { tabId });
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,217 @@ 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.',
366
+ inputSchema: {
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(),
417
+ tabId: tabIdParam,
418
+ },
419
+ },
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
+ );
504
+ return ok(out);
505
+ },
506
+ );
507
+
508
+ server.registerTool(
509
+ COMMAND.NAVIGATION_TAIL,
510
+ {
511
+ description:
512
+ 'Return the last N navigation events captured by the runtime: history.pushState / replaceState, popstate, hashchange, and location.href / location.hash / location.assign() / location.replace(). Each entry has `kind`, `url`, `replace`, and an `initiator.stack` for interceptable kinds. Filter via `filter` (against {kind, url, replace}) or narrow with `kind`. Buffer is in-memory and cleared on navigate — use `session.tail` (type=["navigation"]) for cross-navigate history.',
513
+ inputSchema: {
514
+ n: z.number().int().positive().default(20).optional(),
515
+ filter: filterParam,
516
+ match: matchParam,
517
+ kind: z.enum(['push', 'replace', 'pop', 'hash', 'assign']).optional(),
518
+ tabId: tabIdParam,
519
+ },
520
+ },
521
+ async ({ n, filter, match, kind, tabId }) => {
522
+ const out = await bridge.sendCommand(
523
+ COMMAND.NAVIGATION_TAIL,
524
+ { n: n ?? 20, filter, match, kind },
525
+ { tabId },
526
+ );
527
+ return ok(out);
528
+ },
529
+ );
530
+
531
+ server.registerTool(
532
+ COMMAND.GLOBALS_TAIL,
533
+ {
534
+ description:
535
+ 'Return the last N read/writes to watched window globals. Only fires for keys registered via the install opts `globals.watch` list — global pollution detection or app-state debugging. Each entry has op=get|set|delete, key, value, previousValue (on set), and initiator.stack. Filter via `filter` or narrow with `op` / `key`. Buffer is in-memory; cross-navigate use `session.tail` (type=["globals"]).',
536
+ inputSchema: {
537
+ n: z.number().int().positive().default(20).optional(),
538
+ filter: filterParam,
539
+ match: matchParam,
540
+ op: z.enum(['get', 'set', 'delete']).optional(),
541
+ key: z.string().optional().describe('Exact window key match.'),
542
+ tabId: tabIdParam,
543
+ },
544
+ },
545
+ async ({ n, filter, match, op, key, tabId }) => {
546
+ const out = await bridge.sendCommand(
547
+ COMMAND.GLOBALS_TAIL,
548
+ { n: n ?? 20, filter, match, op, key },
549
+ { tabId },
550
+ );
551
+ return ok(out);
552
+ },
553
+ );
554
+
555
+ server.registerTool(
556
+ COMMAND.INDEXEDDB_TAIL,
557
+ {
558
+ description:
559
+ 'Return the last N IndexedDB operations: open / put / add / get / getAll / delete / clear / cursor. Each entry has `op`, `store`, `key`, `value`, `db`, `version`, `success` and `initiator.stack`. Useful for tracking who reads/writes which IDB store key. Filter via `filter` (against {op, store, key}) or narrow with `op` / `store` / `db`. Buffer is in-memory; cross-navigate use `session.tail` (type=["indexeddb"]).',
350
560
  inputSchema: {
351
561
  n: z.number().int().positive().default(20).optional(),
562
+ filter: filterParam,
563
+ match: matchParam,
564
+ op: z.enum(['open', 'put', 'add', 'get', 'getAll', 'delete', 'clear', 'cursor']).optional(),
565
+ store: z.string().optional().describe('Exact object-store name.'),
566
+ db: z.string().optional().describe('Exact database name (open events only).'),
352
567
  tabId: tabIdParam,
353
568
  },
354
569
  },
355
- async ({ n, tabId }) => {
356
- const out = await bridge.sendCommand(COMMAND.ERRORS_TAIL, { n: n ?? 20 }, { tabId });
570
+ async ({ n, filter, match, op, store, db, tabId }) => {
571
+ const out = await bridge.sendCommand(
572
+ COMMAND.INDEXEDDB_TAIL,
573
+ { n: n ?? 20, filter, match, op, store, db },
574
+ { tabId },
575
+ );
357
576
  return ok(out);
358
577
  },
359
578
  );
@@ -602,7 +821,7 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
602
821
  server.registerTool(
603
822
  'session.tail',
604
823
  {
605
- description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
824
+ 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
825
  inputSchema: {
607
826
  sessionId: z.string(),
608
827
  n: z.number().int().positive().default(50).optional(),
@@ -840,6 +1059,34 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
840
1059
  },
841
1060
  );
842
1061
 
1062
+ server.registerTool(
1063
+ 'visitor.timeline',
1064
+ {
1065
+ description:
1066
+ '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.',
1067
+ inputSchema: {
1068
+ visitorId: z.string(),
1069
+ since: z.number().optional().describe('Only events after this Unix ts (ms)'),
1070
+ until: z.number().optional().describe('Only events before this Unix ts (ms)'),
1071
+ types: z.union([z.string(), z.array(z.string())]).optional()
1072
+ .describe('Filter by event type(s): log, err, req, res, ws, storage, cmd, resp, ...'),
1073
+ tabIds: z.array(z.string()).optional()
1074
+ .describe('Narrow merge to specific tabIds. Default: all tabs known to this visitor.'),
1075
+ sessionIds: z.array(z.string()).optional()
1076
+ .describe('Explicit session list to merge. When set, skips visitor → session discovery.'),
1077
+ limit: z.number().int().positive().optional()
1078
+ .describe('Max events returned (newest). Default 200.'),
1079
+ },
1080
+ },
1081
+ async ({ visitorId, since, until, types, tabIds, sessionIds, limit }) => {
1082
+ const result = buildVisitorTimeline(store, visitorId, {
1083
+ since, until, types, tabIds, sessionIds, limit,
1084
+ });
1085
+ if ('error' in result) return err(result.error);
1086
+ return ok(result);
1087
+ },
1088
+ );
1089
+
843
1090
  server.registerTool(
844
1091
  'session.recordings.list',
845
1092
  {
@@ -1087,7 +1334,7 @@ function registerRemoteStoreTools(server: McpServer, bridge: RemoteBridge): void
1087
1334
  server.registerTool(
1088
1335
  'session.tail',
1089
1336
  {
1090
- description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
1337
+ 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
1338
  inputSchema: {
1092
1339
  sessionId: z.string(),
1093
1340
  n: z.number().int().positive().default(50).optional(),
@@ -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
  });