@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/README.md CHANGED
@@ -123,6 +123,70 @@ Matching env vars: `HARNESS_FE_HOST`, `HARNESS_FE_PORT`,
123
123
  `HARNESS_FE_TOKEN`, `HARNESS_FE_MCP_TRANSPORT`, `HARNESS_FE_MCP_PATH`,
124
124
  `HARNESS_FE_HEADLESS`.
125
125
 
126
+ ## Embedding into a host app
127
+
128
+ For most users, running the CLI as a separate process is the right call.
129
+ If you're shipping your **own** product and want the harness daemon to live
130
+ inside *your* Node process — sharing your auth, your storage, your lifecycle
131
+ — use `createDaemon`:
132
+
133
+ ```ts
134
+ import { createDaemon } from '@harness-fe/mcp-server';
135
+
136
+ const daemon = createDaemon({
137
+ port: 47730, // pick a port distinct from devs' local 47729
138
+ host: '127.0.0.1',
139
+ authorize: (req) => verifyMyJwt(req.headers.authorization), // your auth
140
+ label: 'my-app',
141
+ });
142
+
143
+ await daemon.start();
144
+ process.on('SIGTERM', () => daemon.stop());
145
+ ```
146
+
147
+ That call starts the WS bridge **and** mounts the MCP HTTP transport at
148
+ `/mcp` on the daemon's own listener. The CLI uses the same factory under
149
+ the hood — there is exactly one boot path.
150
+
151
+ ### Picking the right options
152
+
153
+ | Option | When to use |
154
+ |---|---|
155
+ | `authorize: (req) => boolean` | You already have an auth layer (JWT, session cookie, etc.). Return `true` to accept the request. The built-in token check is skipped. |
156
+ | `token: 'xxxx'` | You want the built-in token gate. Mutually exclusive with `authorize`. Same wire conventions as the CLI's `--token`. |
157
+ | `store`, `taskStore`, `memoryStore` | Plug in custom `IStore` implementations to land data in your own DB instead of `~/.harness/`. Pass `null` to disable persistence entirely. |
158
+ | `eventStore` | Custom SSE `EventStore` for resumable streaming. Omit for the in-memory default; pass `null` to disable Last-Event-ID resumption. |
159
+ | `mcpHttp: false` | Boot only the WS bridge; skip mounting `/mcp`. Use when you want to wire MCP through stdio yourself (this is how the CLI's stdio mode embeds the daemon). |
160
+ | `mcpPath: '/agents/mcp'` | Move the MCP HTTP endpoint to a non-default path. |
161
+ | `dataDir` | Override the on-disk root for default JSONL stores. |
162
+
163
+ ### Resumable SSE
164
+
165
+ The MCP HTTP transport supports `Last-Event-ID` reconnection out of the
166
+ box. Long agent runs survive transient network drops — when a client
167
+ reconnects with the `Last-Event-ID` header, the server replays every
168
+ event past that id with no duplicates and no gaps.
169
+
170
+ Defaults: in-memory ring with 1000 events / 5 minutes / 50 MiB cap per
171
+ stream. Override with `eventStore: new MemoryEventStore({...})` or plug
172
+ in your own backend. Set `eventStore: null` to opt out.
173
+
174
+ ### Working example
175
+
176
+ A self-contained example lives at
177
+ [`examples/embed-express/`](https://github.com/Morphicai/harness-fe/tree/main/packages/mcp-server/examples/embed-express):
178
+ an Express app and the harness daemon share one Node process, with a
179
+ custom `authorize` hook standing in for the host's real auth.
180
+
181
+ ### Embedding vs running the CLI: which?
182
+
183
+ - **CLI** is right for development. Devs run `npx @harness-fe/mcp-server`
184
+ alongside their dev server; AI agents (Claude Code / Cursor / Kiro)
185
+ speak to it over stdio MCP. **The CLI is what 99% of users want.**
186
+ - **`createDaemon`** is right when your *product* embeds the harness —
187
+ for example, a hosted dev environment that runs the daemon under its
188
+ own auth so users don't have to install or configure anything.
189
+
126
190
  ## What it exposes
127
191
 
128
192
  Tools across these domains (see [Architecture](https://github.com/Morphicai/harness-fe/blob/main/ARCHITECTURE.md)):
package/dist/cli.js CHANGED
@@ -17,10 +17,10 @@
17
17
  */
18
18
  import { randomBytes } from 'node:crypto';
19
19
  import { DEFAULT_HOST, DEFAULT_WS_PORT, buildHttpUrl, isLoopbackHost, parseWsUrl, } from '@harness-fe/protocol';
20
- import { Bridge, defaultDataDir } from './bridge.js';
20
+ import { defaultDataDir } from './bridge.js';
21
+ import { createDaemon } from './daemon.js';
21
22
  import { RemoteBridge } from './remoteBridge.js';
22
23
  import { startMcpStdioServer } from './mcp.js';
23
- import { startMcpHttpServer } from './mcpHttp.js';
24
24
  function printHelpAndExit() {
25
25
  const help = `harness-fe — frontend harness MCP daemon
26
26
 
@@ -211,30 +211,24 @@ async function main() {
211
211
  validate(cfg);
212
212
  const { active, shutdown, role } = await startBridgeOrAttach(cfg);
213
213
  printBanner(cfg, role, active.getViewerBaseUrl());
214
- let mcpShutdown;
215
214
  if (cfg.mcpTransport === 'stdio') {
216
215
  await startMcpStdioServer(active);
217
216
  process.stderr.write('[harness-fe] MCP stdio server connected\n');
218
217
  }
219
218
  else {
219
+ // HTTP transport: the leader's createDaemon() call already mounted
220
+ // /mcp via mcpHttp:true. Followers fall through here with no leader
221
+ // attached, so HTTP mode is unsupported for them.
220
222
  if (role === 'follower') {
221
223
  process.stderr.write('[harness-fe] --mcp-transport=http is only supported on the leader. ' +
222
224
  'Another daemon already holds the port; stop it first.\n');
223
225
  await shutdown();
224
226
  process.exit(2);
225
227
  }
226
- const handle = await startMcpHttpServer(active, { path: cfg.mcpPath });
227
- process.stderr.write(`[harness-fe] MCP http server mounted at ${handle.path}\n`);
228
- mcpShutdown = () => handle.close();
228
+ process.stderr.write(`[harness-fe] MCP http server mounted at ${cfg.mcpPath}\n`);
229
229
  }
230
230
  const onSignal = async () => {
231
231
  process.stderr.write('[harness-fe] shutting down\n');
232
- if (mcpShutdown) {
233
- try {
234
- await mcpShutdown();
235
- }
236
- catch { /* swallow */ }
237
- }
238
232
  await shutdown();
239
233
  process.exit(0);
240
234
  };
@@ -242,25 +236,33 @@ async function main() {
242
236
  process.on('SIGTERM', onSignal);
243
237
  }
244
238
  async function startBridgeOrAttach(cfg) {
245
- const bridge = new Bridge({
239
+ // Leader path: use createDaemon so there's exactly one boot path between
240
+ // the CLI and any host application that embeds the daemon. The factory
241
+ // mounts /mcp itself when mcpHttp:true, so we don't need to call
242
+ // startMcpHttpServer here.
243
+ const daemon = createDaemon({
246
244
  port: cfg.port,
247
245
  host: cfg.host,
248
246
  dataDir: cfg.dataDir,
249
247
  label: cfg.label,
250
- auth: cfg.token ? { token: cfg.token } : undefined,
248
+ token: cfg.token,
251
249
  publicHost: cfg.publicHost,
250
+ mcpHttp: cfg.mcpTransport === 'http',
251
+ mcpPath: cfg.mcpPath,
252
252
  });
253
253
  try {
254
- await bridge.start();
254
+ await daemon.start();
255
255
  return {
256
- active: bridge,
257
- shutdown: () => bridge.stop(),
256
+ active: daemon.bridge,
257
+ shutdown: () => daemon.stop(),
258
258
  role: 'leader',
259
259
  };
260
260
  }
261
261
  catch (err) {
262
262
  if (err?.code !== 'EADDRINUSE')
263
263
  throw err;
264
+ // Factory's bridge.start failed on EADDRINUSE; the factory itself
265
+ // didn't mount anything else, so there's nothing further to clean up.
264
266
  }
265
267
  // Port already taken — attach as follower.
266
268
  const remote = new RemoteBridge({ port: cfg.port, host: cfg.host, token: cfg.token });
package/dist/daemon.d.ts CHANGED
@@ -43,8 +43,19 @@ export interface DaemonOptions {
43
43
  * skipped — there is exactly one auth pipeline. Synchronous because the
44
44
  * WS upgrade handshake completes inline; async auth should be cached in
45
45
  * a cookie by the host's own middleware and read back here.
46
+ *
47
+ * Mutually exclusive with `token`. If both are provided, `authorize`
48
+ * wins and `token` is ignored.
46
49
  */
47
50
  authorize?: (req: IncomingMessage) => boolean;
51
+ /**
52
+ * Static token; auth requires `Authorization: Bearer <token>`, the
53
+ * `harness_fe_token` cookie, `?token=<token>`, or the matching WS
54
+ * subprotocol. Use this when you want the daemon's built-in auth and
55
+ * have no reason to plug in custom logic — the CLI's `--token` flag
56
+ * flows through here. Mutually exclusive with `authorize`.
57
+ */
58
+ token?: string;
48
59
  /**
49
60
  * IStore implementation. Omit for the default JSONL store at `dataDir`.
50
61
  * Pass `null` to disable session/event persistence entirely.
@@ -76,6 +87,14 @@ export interface DaemonOptions {
76
87
  * Claude Code, Cursor, and the MCP spec default expect).
77
88
  */
78
89
  mcpStateful?: boolean;
90
+ /**
91
+ * Whether to mount the MCP HTTP Streamable transport at `mcpPath`.
92
+ * Default `true`. Set to `false` if the host only needs the WS bridge
93
+ * (and wires up MCP over stdio externally — that's how the CLI's
94
+ * stdio mode boots the daemon through this factory without also
95
+ * exposing an HTTP MCP endpoint).
96
+ */
97
+ mcpHttp?: boolean;
79
98
  }
80
99
  export interface DaemonHandle {
81
100
  /**
package/dist/daemon.js CHANGED
@@ -26,6 +26,15 @@ import { Bridge } from './bridge.js';
26
26
  import { startMcpHttpServer } from './mcpHttp.js';
27
27
  export function createDaemon(opts = {}) {
28
28
  const mcpPath = opts.mcpPath ?? '/mcp';
29
+ const mountMcpHttp = opts.mcpHttp ?? true;
30
+ // Resolve auth: authorize wins if both are set. `undefined` here means
31
+ // no auth at all (loopback dev default). One pipeline — Bridge sees
32
+ // exactly one of `{ authorize }`, `{ token }`, or nothing.
33
+ const auth = opts.authorize
34
+ ? { authorize: opts.authorize }
35
+ : opts.token
36
+ ? { token: opts.token }
37
+ : undefined;
29
38
  const bridge = new Bridge({
30
39
  port: opts.port,
31
40
  host: opts.host,
@@ -35,7 +44,7 @@ export function createDaemon(opts = {}) {
35
44
  memoryStore: opts.memoryStore,
36
45
  dataDir: opts.dataDir,
37
46
  label: opts.label,
38
- auth: opts.authorize ? { authorize: opts.authorize } : undefined,
47
+ auth,
39
48
  });
40
49
  let mcpHandle;
41
50
  let started = false;
@@ -48,13 +57,15 @@ export function createDaemon(opts = {}) {
48
57
  return;
49
58
  started = true;
50
59
  await bridge.start();
51
- // Forward eventStore as-is so `null` opts out of resumability and
52
- // `undefined` falls through to the default MemoryEventStore.
53
- mcpHandle = await startMcpHttpServer(bridge, {
54
- path: mcpPath,
55
- stateful: opts.mcpStateful,
56
- eventStore: opts.eventStore,
57
- });
60
+ if (mountMcpHttp) {
61
+ // Forward eventStore as-is so `null` opts out of resumability and
62
+ // `undefined` falls through to the default MemoryEventStore.
63
+ mcpHandle = await startMcpHttpServer(bridge, {
64
+ path: mcpPath,
65
+ stateful: opts.mcpStateful,
66
+ eventStore: opts.eventStore,
67
+ });
68
+ }
58
69
  },
59
70
  async stop() {
60
71
  if (stopped)
package/dist/mcp.js CHANGED
@@ -10,6 +10,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
10
  import { z } from 'zod';
11
11
  import { COMMAND, PROTOCOL_VERSION, clickArgsSchema, evaluateArgsSchema, navigateArgsSchema, reloadArgsSchema, screenshotArgsSchema, scrollArgsSchema, setHtmlArgsSchema, setStyleArgsSchema, selectorSchema, typeArgsSchema, waitForArgsSchema, } from '@harness-fe/protocol';
12
12
  import { RemoteBridge } from './remoteBridge.js';
13
+ import { buildVisitorTimeline } from './visitorTimeline.js';
13
14
  import { createReplayExport } from './replayCreate.js';
14
15
  import { openBrowser } from './openBrowser.js';
15
16
  import { buildDashboardUrl } from './dashboardUrl.js';
@@ -203,35 +204,162 @@ function registerTools(server, bridge) {
203
204
  const out = await bridge.sendCommand(COMMAND.PAGE_SET_STYLE, args, { tabId });
204
205
  return ok(out);
205
206
  });
207
+ const filterParam = z.string().optional().describe('Substring or regex (see `match`). Filters entries by their serialized payload before return.');
208
+ const matchParam = z.enum(['contains', 'regex']).optional().describe('How to interpret `filter`. Default: contains (case-insensitive). regex is case-insensitive too.');
206
209
  server.registerTool(COMMAND.CONSOLE_TAIL, {
207
- description: 'Return the last N console entries from the page.',
210
+ 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.',
208
211
  inputSchema: {
209
212
  n: z.number().int().positive().default(20).optional(),
213
+ filter: filterParam,
214
+ match: matchParam,
215
+ level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
210
216
  tabId: tabIdParam,
211
217
  },
212
- }, async ({ n, tabId }) => {
213
- const out = await bridge.sendCommand(COMMAND.CONSOLE_TAIL, { n: n ?? 20 }, { tabId });
218
+ }, async ({ n, filter, match, level, tabId }) => {
219
+ const out = await bridge.sendCommand(COMMAND.CONSOLE_TAIL, { n: n ?? 20, filter, match, level }, { tabId });
214
220
  return ok(out);
215
221
  });
216
222
  server.registerTool(COMMAND.NETWORK_TAIL, {
217
- description: 'Return the last N network requests captured by the runtime client.',
223
+ 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.',
218
224
  inputSchema: {
219
225
  n: z.number().int().positive().default(20).optional(),
220
226
  includeBody: z.boolean().optional(),
227
+ filter: filterParam,
228
+ match: matchParam,
229
+ urlContains: z.string().optional().describe('Substring filter on url (case-sensitive).'),
230
+ method: z.string().optional().describe('Exact HTTP method match (e.g. "POST"). Case-insensitive.'),
231
+ statusCode: z.number().int().optional().describe('Exact status code match (response entries only).'),
221
232
  tabId: tabIdParam,
222
233
  },
223
- }, async ({ n, includeBody, tabId }) => {
224
- const out = await bridge.sendCommand(COMMAND.NETWORK_TAIL, { n: n ?? 20, includeBody: includeBody ?? false }, { tabId });
234
+ }, async ({ n, includeBody, filter, match, urlContains, method, statusCode, tabId }) => {
235
+ const out = await bridge.sendCommand(COMMAND.NETWORK_TAIL, { n: n ?? 20, includeBody: includeBody ?? false, filter, match, urlContains, method, statusCode }, { tabId });
225
236
  return ok(out);
226
237
  });
227
238
  server.registerTool(COMMAND.ERRORS_TAIL, {
228
- description: 'Return the last N JavaScript errors captured by the runtime client.',
239
+ 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.',
229
240
  inputSchema: {
230
241
  n: z.number().int().positive().default(20).optional(),
242
+ filter: filterParam,
243
+ match: matchParam,
231
244
  tabId: tabIdParam,
232
245
  },
233
- }, async ({ n, tabId }) => {
234
- const out = await bridge.sendCommand(COMMAND.ERRORS_TAIL, { n: n ?? 20 }, { tabId });
246
+ }, async ({ n, filter, match, tabId }) => {
247
+ const out = await bridge.sendCommand(COMMAND.ERRORS_TAIL, { n: n ?? 20, filter, match }, { tabId });
248
+ return ok(out);
249
+ });
250
+ server.registerTool(COMMAND.WS_TAIL, {
251
+ description: '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.',
252
+ inputSchema: {
253
+ n: z.number().int().positive().default(20).optional(),
254
+ filter: filterParam,
255
+ match: matchParam,
256
+ phase: z.enum(['open', 'send', 'recv', 'close']).optional(),
257
+ tabId: tabIdParam,
258
+ },
259
+ }, async ({ n, filter, match, phase, tabId }) => {
260
+ const out = await bridge.sendCommand(COMMAND.WS_TAIL, { n: n ?? 20, filter, match, phase }, { tabId });
261
+ return ok(out);
262
+ });
263
+ server.registerTool(COMMAND.NETWORK_WAIT_FOR, {
264
+ description: '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.',
265
+ inputSchema: {
266
+ urlContains: z.string().optional(),
267
+ urlRegex: z.string().optional().describe('Case-insensitive regex against url.'),
268
+ method: z.string().optional(),
269
+ statusCode: z.number().int().optional(),
270
+ timeoutMs: z.number().int().positive().default(10000).optional(),
271
+ tabId: tabIdParam,
272
+ },
273
+ }, async ({ urlContains, urlRegex, method, statusCode, timeoutMs, tabId }) => {
274
+ const out = await bridge.sendCommand(COMMAND.NETWORK_WAIT_FOR, { urlContains, urlRegex, method, statusCode, timeoutMs }, { tabId });
275
+ return ok(out);
276
+ });
277
+ server.registerTool(COMMAND.NETWORK_WAIT_FOR_IDLE, {
278
+ description: '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.',
279
+ inputSchema: {
280
+ idleMs: z.number().int().positive().default(500).optional(),
281
+ timeoutMs: z.number().int().positive().default(10000).optional(),
282
+ tabId: tabIdParam,
283
+ },
284
+ }, async ({ idleMs, timeoutMs, tabId }) => {
285
+ const out = await bridge.sendCommand(COMMAND.NETWORK_WAIT_FOR_IDLE, { idleMs, timeoutMs }, { tabId });
286
+ return ok(out);
287
+ });
288
+ server.registerTool(COMMAND.NETWORK_GET, {
289
+ description: '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.',
290
+ inputSchema: {
291
+ reqId: z.string().describe('id field from a `network.tail` entry'),
292
+ tabId: tabIdParam,
293
+ },
294
+ }, async ({ reqId, tabId }) => {
295
+ const out = await bridge.sendCommand(COMMAND.NETWORK_GET, { reqId }, { tabId });
296
+ return ok(out);
297
+ });
298
+ server.registerTool(COMMAND.WS_GET, {
299
+ description: '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.',
300
+ inputSchema: {
301
+ wsId: z.string().describe('id field from a `ws.tail` entry'),
302
+ tabId: tabIdParam,
303
+ },
304
+ }, async ({ wsId, tabId }) => {
305
+ const out = await bridge.sendCommand(COMMAND.WS_GET, { wsId }, { tabId });
306
+ return ok(out);
307
+ });
308
+ server.registerTool(COMMAND.STORAGE_TAIL, {
309
+ description: '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.',
310
+ inputSchema: {
311
+ n: z.number().int().positive().default(20).optional(),
312
+ filter: filterParam,
313
+ match: matchParam,
314
+ which: z.enum(['local', 'session', 'cookie']).optional(),
315
+ op: z.enum(['set', 'remove', 'clear']).optional(),
316
+ key: z.string().optional().describe('Exact key match (case-sensitive).'),
317
+ tabId: tabIdParam,
318
+ },
319
+ }, async ({ n, filter, match, which, op, key, tabId }) => {
320
+ const out = await bridge.sendCommand(COMMAND.STORAGE_TAIL, { n: n ?? 20, filter, match, which, op, key }, { tabId });
321
+ return ok(out);
322
+ });
323
+ server.registerTool(COMMAND.NAVIGATION_TAIL, {
324
+ description: '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.',
325
+ inputSchema: {
326
+ n: z.number().int().positive().default(20).optional(),
327
+ filter: filterParam,
328
+ match: matchParam,
329
+ kind: z.enum(['push', 'replace', 'pop', 'hash', 'assign']).optional(),
330
+ tabId: tabIdParam,
331
+ },
332
+ }, async ({ n, filter, match, kind, tabId }) => {
333
+ const out = await bridge.sendCommand(COMMAND.NAVIGATION_TAIL, { n: n ?? 20, filter, match, kind }, { tabId });
334
+ return ok(out);
335
+ });
336
+ server.registerTool(COMMAND.GLOBALS_TAIL, {
337
+ description: '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"]).',
338
+ inputSchema: {
339
+ n: z.number().int().positive().default(20).optional(),
340
+ filter: filterParam,
341
+ match: matchParam,
342
+ op: z.enum(['get', 'set', 'delete']).optional(),
343
+ key: z.string().optional().describe('Exact window key match.'),
344
+ tabId: tabIdParam,
345
+ },
346
+ }, async ({ n, filter, match, op, key, tabId }) => {
347
+ const out = await bridge.sendCommand(COMMAND.GLOBALS_TAIL, { n: n ?? 20, filter, match, op, key }, { tabId });
348
+ return ok(out);
349
+ });
350
+ server.registerTool(COMMAND.INDEXEDDB_TAIL, {
351
+ description: '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"]).',
352
+ inputSchema: {
353
+ n: z.number().int().positive().default(20).optional(),
354
+ filter: filterParam,
355
+ match: matchParam,
356
+ op: z.enum(['open', 'put', 'add', 'get', 'getAll', 'delete', 'clear', 'cursor']).optional(),
357
+ store: z.string().optional().describe('Exact object-store name.'),
358
+ db: z.string().optional().describe('Exact database name (open events only).'),
359
+ tabId: tabIdParam,
360
+ },
361
+ }, async ({ n, filter, match, op, store, db, tabId }) => {
362
+ const out = await bridge.sendCommand(COMMAND.INDEXEDDB_TAIL, { n: n ?? 20, filter, match, op, store, db }, { tabId });
235
363
  return ok(out);
236
364
  });
237
365
  server.registerTool(COMMAND.TAB_LIST, {
@@ -394,7 +522,7 @@ function registerStoreTools(server, store, memoryStore, bridge) {
394
522
  return ok(summary);
395
523
  });
396
524
  server.registerTool('session.tail', {
397
- description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
525
+ 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.',
398
526
  inputSchema: {
399
527
  sessionId: z.string(),
400
528
  n: z.number().int().positive().default(50).optional(),
@@ -562,6 +690,29 @@ function registerStoreTools(server, store, memoryStore, bridge) {
562
690
  const slice = limit ? sessionsOut.slice(0, limit) : sessionsOut;
563
691
  return ok({ visitor, sessions: slice });
564
692
  });
693
+ server.registerTool('visitor.timeline', {
694
+ description: '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.',
695
+ inputSchema: {
696
+ visitorId: z.string(),
697
+ since: z.number().optional().describe('Only events after this Unix ts (ms)'),
698
+ until: z.number().optional().describe('Only events before this Unix ts (ms)'),
699
+ types: z.union([z.string(), z.array(z.string())]).optional()
700
+ .describe('Filter by event type(s): log, err, req, res, ws, storage, cmd, resp, ...'),
701
+ tabIds: z.array(z.string()).optional()
702
+ .describe('Narrow merge to specific tabIds. Default: all tabs known to this visitor.'),
703
+ sessionIds: z.array(z.string()).optional()
704
+ .describe('Explicit session list to merge. When set, skips visitor → session discovery.'),
705
+ limit: z.number().int().positive().optional()
706
+ .describe('Max events returned (newest). Default 200.'),
707
+ },
708
+ }, async ({ visitorId, since, until, types, tabIds, sessionIds, limit }) => {
709
+ const result = buildVisitorTimeline(store, visitorId, {
710
+ since, until, types, tabIds, sessionIds, limit,
711
+ });
712
+ if ('error' in result)
713
+ return err(result.error);
714
+ return ok(result);
715
+ });
565
716
  server.registerTool('session.recordings.list', {
566
717
  description: 'List rrweb recording chunks available for a session.',
567
718
  inputSchema: {
@@ -727,7 +878,7 @@ function registerRemoteStoreTools(server, bridge) {
727
878
  return ok(summary);
728
879
  });
729
880
  server.registerTool('session.tail', {
730
- description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
881
+ 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.',
731
882
  inputSchema: {
732
883
  sessionId: z.string(),
733
884
  n: z.number().int().positive().default(50).optional(),
@@ -19,7 +19,7 @@
19
19
  import type { Task, VisitorEnv } from '@harness-fe/protocol';
20
20
  export type { EventId, EventStore, StreamId, } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
21
21
  /** Short type codes used in JSONL lines to keep files compact. */
22
- export type EventType = 'log' | 'err' | 'req' | 'res' | 'cmd' | 'resp' | 'hmr' | 'task' | 'task:claim' | 'task:resolve' | 'rrweb' | 'node:log' | 'node:err' | 'note' | 'load' | 'storage' | 'server-log' | 'server-err' | 'server-action' | 'app-log' | string;
22
+ export type EventType = 'log' | 'err' | 'req' | 'res' | 'cmd' | 'resp' | 'hmr' | 'task' | 'task:claim' | 'task:resolve' | 'rrweb' | 'node:log' | 'node:err' | 'note' | 'load' | 'storage' | 'ws' | 'navigation' | 'globals' | 'indexeddb' | 'server-log' | 'server-err' | 'server-action' | 'app-log' | string;
23
23
  /** A single event line in a JSONL file. Carries row-level projectId/buildId tags. */
24
24
  export interface StoreEvent {
25
25
  /**
@@ -0,0 +1,24 @@
1
+ /**
2
+ * visitor.timeline — merge event timelines across all sessions belonging to
3
+ * one visitor. Pulled out of mcp.ts so the merge / filter logic is unit
4
+ * testable without spinning up an McpServer.
5
+ */
6
+ import type { IStore, StoreEvent } from './store/index.js';
7
+ export interface VisitorTimelineOptions {
8
+ since?: number;
9
+ until?: number;
10
+ types?: string | string[];
11
+ tabIds?: string[];
12
+ sessionIds?: string[];
13
+ limit?: number;
14
+ }
15
+ export interface VisitorTimelineResult {
16
+ visitorId: string;
17
+ sessionCount: number;
18
+ eventCount: number;
19
+ truncated: boolean;
20
+ events: StoreEvent[];
21
+ }
22
+ export declare function buildVisitorTimeline(store: IStore, visitorId: string, opts?: VisitorTimelineOptions): VisitorTimelineResult | {
23
+ error: string;
24
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * visitor.timeline — merge event timelines across all sessions belonging to
3
+ * one visitor. Pulled out of mcp.ts so the merge / filter logic is unit
4
+ * testable without spinning up an McpServer.
5
+ */
6
+ const DEFAULT_LIMIT = 200;
7
+ const SESSION_DISCOVERY_PAGE = 200;
8
+ export function buildVisitorTimeline(store, visitorId, opts = {}) {
9
+ const visitor = store.getVisitor(visitorId);
10
+ if (!visitor)
11
+ return { error: `visitor not found: ${visitorId}` };
12
+ const cap = opts.limit ?? DEFAULT_LIMIT;
13
+ const tabFilter = opts.tabIds && opts.tabIds.length > 0 ? new Set(opts.tabIds) : undefined;
14
+ // 1. Discover candidate sessions (or honor the explicit list).
15
+ const candidateIds = new Set();
16
+ if (opts.sessionIds && opts.sessionIds.length > 0) {
17
+ for (const id of opts.sessionIds)
18
+ candidateIds.add(id);
19
+ }
20
+ else {
21
+ const visitorTabs = new Set(visitor.tabIds);
22
+ for (const pid of visitor.projectIds) {
23
+ for (const sess of store.listSessions({ projectId: pid, limit: SESSION_DISCOVERY_PAGE })) {
24
+ if (candidateIds.has(sess.id))
25
+ continue;
26
+ if (sess.tabId && !visitorTabs.has(sess.tabId))
27
+ continue;
28
+ if (tabFilter && sess.tabId && !tabFilter.has(sess.tabId))
29
+ continue;
30
+ candidateIds.add(sess.id);
31
+ }
32
+ }
33
+ }
34
+ // 2. Pull tail() from each session and merge. Over-fetch by 1 per session
35
+ // so we can detect single-session truncation (tail returns exactly `cap`
36
+ // when there are more, indistinguishable from "the session had exactly
37
+ // `cap` events" otherwise).
38
+ const merged = [];
39
+ let perSessionTruncated = false;
40
+ for (const sid of candidateIds) {
41
+ const events = store.tail(sid, {
42
+ n: cap + 1,
43
+ type: opts.types,
44
+ since: opts.since,
45
+ until: opts.until,
46
+ });
47
+ if (events.length > cap)
48
+ perSessionTruncated = true;
49
+ for (const ev of events) {
50
+ if (ev.visitorId && ev.visitorId !== visitorId)
51
+ continue;
52
+ if (tabFilter && ev.tab && !tabFilter.has(ev.tab))
53
+ continue;
54
+ merged.push(ev);
55
+ }
56
+ }
57
+ // 3. Ascending sort, then trim to the newest `cap` events.
58
+ merged.sort((a, b) => a.ts - b.ts);
59
+ const truncated = perSessionTruncated || merged.length > cap;
60
+ const slice = merged.length > cap ? merged.slice(merged.length - cap) : merged;
61
+ return {
62
+ visitorId,
63
+ sessionCount: candidateIds.size,
64
+ eventCount: slice.length,
65
+ truncated,
66
+ events: slice,
67
+ };
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/mcp-server",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "Unified MCP daemon: stdio MCP for AI agents + WS bridge for Vite plugin and runtime client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "rrweb-player": "1.0.0-alpha.4",
39
39
  "ws": "^8.18.0",
40
40
  "zod": "^4.4.3",
41
- "@harness-fe/protocol": "3.0.0",
42
- "@harness-fe/dashboard-ui": "0.2.0"
41
+ "@harness-fe/dashboard-ui": "0.2.0",
42
+ "@harness-fe/protocol": "3.2.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/ws": "^8.5.10",