@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 +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 +162 -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 +258 -11
- package/src/mcpHttp.test.ts +102 -0
- package/src/mcpLayer.e2e.test.ts +249 -0
- package/src/newCapabilities.e2e.test.ts +317 -0
- package/src/store/types.ts +4 -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,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(),
|
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' | '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
|
|
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/
|
|
42
|
-
"@harness-fe/
|
|
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",
|