@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/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,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 ({ n, tabId }) => {
356
- const out = await bridge.sendCommand(COMMAND.ERRORS_TAIL, { n: n ?? 20 }, { tabId });
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(),
@@ -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
  });