@harness-fe/mcp-server 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/cli.js +19 -17
- package/dist/daemon.d.ts +19 -0
- package/dist/daemon.js +19 -8
- package/dist/mcp.js +120 -11
- package/dist/store/types.d.ts +1 -1
- package/dist/visitorTimeline.d.ts +24 -0
- package/dist/visitorTimeline.js +68 -0
- package/package.json +3 -3
- package/src/cli.ts +19 -14
- package/src/daemon.ts +39 -8
- package/src/mcp.ts +186 -11
- package/src/mcpHttp.test.ts +102 -0
- package/src/mcpLayer.e2e.test.ts +235 -0
- package/src/newCapabilities.e2e.test.ts +303 -0
- package/src/store/types.ts +1 -0
- package/src/visitorTimeline.test.ts +197 -0
- package/src/visitorTimeline.ts +89 -0
package/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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
254
|
+
await daemon.start();
|
|
255
255
|
return {
|
|
256
|
-
active: bridge,
|
|
257
|
-
shutdown: () =>
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(),
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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
|
|
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/
|
|
42
|
-
"@harness-fe/
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
295
|
+
await daemon.start();
|
|
293
296
|
return {
|
|
294
|
-
active: bridge,
|
|
295
|
-
shutdown: () =>
|
|
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.
|