@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 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,120 @@ 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 });
235
321
  return ok(out);
236
322
  });
237
323
  server.registerTool(COMMAND.TAB_LIST, {
@@ -394,7 +480,7 @@ function registerStoreTools(server, store, memoryStore, bridge) {
394
480
  return ok(summary);
395
481
  });
396
482
  server.registerTool('session.tail', {
397
- description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
483
+ 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
484
  inputSchema: {
399
485
  sessionId: z.string(),
400
486
  n: z.number().int().positive().default(50).optional(),
@@ -562,6 +648,29 @@ function registerStoreTools(server, store, memoryStore, bridge) {
562
648
  const slice = limit ? sessionsOut.slice(0, limit) : sessionsOut;
563
649
  return ok({ visitor, sessions: slice });
564
650
  });
651
+ server.registerTool('visitor.timeline', {
652
+ 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.',
653
+ inputSchema: {
654
+ visitorId: z.string(),
655
+ since: z.number().optional().describe('Only events after this Unix ts (ms)'),
656
+ until: z.number().optional().describe('Only events before this Unix ts (ms)'),
657
+ types: z.union([z.string(), z.array(z.string())]).optional()
658
+ .describe('Filter by event type(s): log, err, req, res, ws, storage, cmd, resp, ...'),
659
+ tabIds: z.array(z.string()).optional()
660
+ .describe('Narrow merge to specific tabIds. Default: all tabs known to this visitor.'),
661
+ sessionIds: z.array(z.string()).optional()
662
+ .describe('Explicit session list to merge. When set, skips visitor → session discovery.'),
663
+ limit: z.number().int().positive().optional()
664
+ .describe('Max events returned (newest). Default 200.'),
665
+ },
666
+ }, async ({ visitorId, since, until, types, tabIds, sessionIds, limit }) => {
667
+ const result = buildVisitorTimeline(store, visitorId, {
668
+ since, until, types, tabIds, sessionIds, limit,
669
+ });
670
+ if ('error' in result)
671
+ return err(result.error);
672
+ return ok(result);
673
+ });
565
674
  server.registerTool('session.recordings.list', {
566
675
  description: 'List rrweb recording chunks available for a session.',
567
676
  inputSchema: {
@@ -727,7 +836,7 @@ function registerRemoteStoreTools(server, bridge) {
727
836
  return ok(summary);
728
837
  });
729
838
  server.registerTool('session.tail', {
730
- description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
839
+ 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
840
  inputSchema: {
732
841
  sessionId: z.string(),
733
842
  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' | '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.1.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.1.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/ws": "^8.5.10",
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.