@harness-fe/mcp-server 3.0.1

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,1407 @@
1
+ /**
2
+ * MCP stdio server — what Claude Code / Cursor connect to.
3
+ *
4
+ * Tools are registered with the underlying `@modelcontextprotocol/sdk`
5
+ * server. Each tool resolves args via Zod, then forwards a CommandFrame
6
+ * to the active runtime-client via the bridge.
7
+ */
8
+
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { z } from 'zod';
12
+ import {
13
+ COMMAND,
14
+ PROTOCOL_VERSION,
15
+ clickArgsSchema,
16
+ evaluateArgsSchema,
17
+ navigateArgsSchema,
18
+ reloadArgsSchema,
19
+ screenshotArgsSchema,
20
+ scrollArgsSchema,
21
+ setHtmlArgsSchema,
22
+ setStyleArgsSchema,
23
+ selectorSchema,
24
+ typeArgsSchema,
25
+ waitForArgsSchema,
26
+ } from '@harness-fe/protocol';
27
+ import type { IBridge } from './bridge.js';
28
+ import type { Bridge } from './bridge.js';
29
+ import { RemoteBridge } from './remoteBridge.js';
30
+ import type { IStore, IMemoryStore } from './store/index.js';
31
+ import { createReplayExport } from './replayCreate.js';
32
+ import { openBrowser } from './openBrowser.js';
33
+ import { buildDashboardUrl } from './dashboardUrl.js';
34
+
35
+ const SERVER_NAME = 'harness-fe';
36
+ const tabIdParam = z
37
+ .string()
38
+ .optional()
39
+ .describe('Optional tab id (from tab.list). Default = most-recent active tab.');
40
+
41
+ /**
42
+ * Build an McpServer with every harness-fe tool registered for the given
43
+ * bridge. Transport (stdio / HTTP) is attached separately.
44
+ */
45
+ export function createMcpServer(bridge: IBridge): McpServer {
46
+ const server = new McpServer({
47
+ name: SERVER_NAME,
48
+ version: PROTOCOL_VERSION,
49
+ });
50
+
51
+ registerTools(server, bridge);
52
+
53
+ // Register store tools for both leader (direct store access) and follower
54
+ // (proxied via RemoteBridge → mcp.call channel to the leader).
55
+ const leaderStore = (bridge as Bridge).store;
56
+ if (leaderStore != null) {
57
+ const memoryStore = bridge.getMemoryStore();
58
+ registerStoreTools(server, leaderStore, memoryStore, bridge);
59
+ } else if (bridge instanceof RemoteBridge) {
60
+ registerRemoteStoreTools(server, bridge);
61
+ }
62
+
63
+ return server;
64
+ }
65
+
66
+ export async function startMcpStdioServer(bridge: IBridge): Promise<McpServer> {
67
+ const server = createMcpServer(bridge);
68
+ const transport = new StdioServerTransport();
69
+ await server.connect(transport);
70
+ return server;
71
+ }
72
+
73
+ function ok<T>(value: T): { content: Array<{ type: 'text'; text: string }> } {
74
+ return {
75
+ content: [
76
+ {
77
+ type: 'text',
78
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
79
+ },
80
+ ],
81
+ };
82
+ }
83
+
84
+ function err(message: string): {
85
+ content: Array<{ type: 'text'; text: string }>;
86
+ isError: true;
87
+ } {
88
+ return {
89
+ content: [{ type: 'text', text: message }],
90
+ isError: true,
91
+ };
92
+ }
93
+
94
+
95
+ function registerTools(server: McpServer, bridge: IBridge): void {
96
+ server.registerTool(
97
+ COMMAND.PAGE_CLICK,
98
+ {
99
+ description: 'Click on a DOM element resolved by the selector.',
100
+ inputSchema: {
101
+ selector: selectorSchema,
102
+ button: z.enum(['left', 'middle', 'right']).optional(),
103
+ tabId: tabIdParam,
104
+ },
105
+ },
106
+ async ({ selector, button, tabId }) => {
107
+ const args = clickArgsSchema.parse({ selector, button });
108
+ const out = await bridge.sendCommand(COMMAND.PAGE_CLICK, args, { tabId });
109
+ return ok(out);
110
+ },
111
+ );
112
+
113
+ server.registerTool(
114
+ COMMAND.PAGE_TYPE,
115
+ {
116
+ description: 'Type a value into an input/textarea resolved by the selector.',
117
+ inputSchema: {
118
+ selector: selectorSchema,
119
+ value: z.string(),
120
+ clear: z.boolean().optional(),
121
+ tabId: tabIdParam,
122
+ },
123
+ },
124
+ async ({ selector, value, clear, tabId }) => {
125
+ const args = typeArgsSchema.parse({ selector, value, clear });
126
+ const out = await bridge.sendCommand(COMMAND.PAGE_TYPE, args, { tabId });
127
+ return ok(out);
128
+ },
129
+ );
130
+
131
+ server.registerTool(
132
+ COMMAND.PAGE_EVALUATE,
133
+ {
134
+ description:
135
+ 'Evaluate a JS expression in page context. The expression must return a JSON-serializable value.',
136
+ inputSchema: {
137
+ expr: z.string(),
138
+ tabId: tabIdParam,
139
+ },
140
+ },
141
+ async ({ expr, tabId }) => {
142
+ const args = evaluateArgsSchema.parse({ expr });
143
+ const out = await bridge.sendCommand(COMMAND.PAGE_EVALUATE, args, { tabId });
144
+ return ok(out);
145
+ },
146
+ );
147
+
148
+ server.registerTool(
149
+ COMMAND.PAGE_WAIT_FOR,
150
+ {
151
+ description:
152
+ 'Wait until a predicate becomes truthy. Built-ins: "network.idle", "dom.ready". Otherwise treated as a JS expression.',
153
+ inputSchema: {
154
+ predicate: z.string(),
155
+ timeoutMs: z.number().int().positive().optional(),
156
+ tabId: tabIdParam,
157
+ },
158
+ },
159
+ async ({ predicate, timeoutMs, tabId }) => {
160
+ const args = waitForArgsSchema.parse({ predicate, timeoutMs });
161
+ const out = await bridge.sendCommand(COMMAND.PAGE_WAIT_FOR, args, { tabId });
162
+ return ok(out);
163
+ },
164
+ );
165
+
166
+ server.registerTool(
167
+ COMMAND.PAGE_SCREENSHOT,
168
+ {
169
+ description:
170
+ 'Take a screenshot. Without `selector`, the full viewport is captured.',
171
+ inputSchema: {
172
+ selector: selectorSchema.optional(),
173
+ format: z.enum(['png', 'webp', 'jpeg']).optional(),
174
+ maxWidth: z.number().int().positive().optional(),
175
+ tabId: tabIdParam,
176
+ },
177
+ },
178
+ async ({ selector, format, maxWidth, tabId }) => {
179
+ const args = screenshotArgsSchema.parse({ selector, format, maxWidth });
180
+ const out = await bridge.sendCommand(COMMAND.PAGE_SCREENSHOT, args, { tabId });
181
+ return ok(out);
182
+ },
183
+ );
184
+
185
+ server.registerTool(
186
+ COMMAND.PAGE_DOM_QUERY,
187
+ {
188
+ description:
189
+ 'Return outerHTML of the matched element(s). Text-first inspection tool.',
190
+ inputSchema: {
191
+ selector: selectorSchema,
192
+ limit: z.number().int().positive().optional(),
193
+ tabId: tabIdParam,
194
+ },
195
+ },
196
+ async ({ selector, limit, tabId }) => {
197
+ const out = await bridge.sendCommand(
198
+ COMMAND.PAGE_DOM_QUERY,
199
+ { selector, limit },
200
+ { tabId },
201
+ );
202
+ return ok(out);
203
+ },
204
+ );
205
+
206
+ server.registerTool(
207
+ COMMAND.PAGE_SCROLL,
208
+ {
209
+ description:
210
+ 'Scroll the page or a specific element. Omit selector to scroll the whole page.',
211
+ inputSchema: {
212
+ selector: selectorSchema.optional(),
213
+ x: z.number().optional().describe('Pixels to scroll on the x-axis. Default 0.'),
214
+ y: z.number().optional().describe('Pixels to scroll on the y-axis. Default 0.'),
215
+ behavior: z.enum(['smooth', 'instant']).optional().describe('Default smooth.'),
216
+ tabId: tabIdParam,
217
+ },
218
+ },
219
+ async ({ selector, x, y, behavior, tabId }) => {
220
+ const args = scrollArgsSchema.parse({ selector, x, y, behavior });
221
+ const out = await bridge.sendCommand(COMMAND.PAGE_SCROLL, args, { tabId });
222
+ return ok(out);
223
+ },
224
+ );
225
+
226
+ server.registerTool(
227
+ COMMAND.PAGE_NAVIGATE,
228
+ {
229
+ description:
230
+ "Navigate to a URL or path. Use method='href' for a full page load (default), 'push' or 'replace' for SPA soft navigation without a full reload.",
231
+ inputSchema: {
232
+ url: z.string().describe("Target URL or path, e.g. '/dashboard' or 'https://example.com'."),
233
+ method: z
234
+ .enum(['href', 'push', 'replace'])
235
+ .optional()
236
+ .describe("'href' = full load (default). 'push'/'replace' = history API + popstate, no reload."),
237
+ tabId: tabIdParam,
238
+ },
239
+ },
240
+ async ({ url, method, tabId }) => {
241
+ const args = navigateArgsSchema.parse({ url, method });
242
+ const out = await bridge.sendCommand(COMMAND.PAGE_NAVIGATE, args, { tabId });
243
+ return ok(out);
244
+ },
245
+ );
246
+
247
+ server.registerTool(
248
+ COMMAND.PAGE_RELOAD,
249
+ {
250
+ description: 'Reload the current page. Use hard=true to bypass the browser cache.',
251
+ inputSchema: {
252
+ hard: z.boolean().optional().describe('Bypass browser cache. Default false.'),
253
+ tabId: tabIdParam,
254
+ },
255
+ },
256
+ async ({ hard, tabId }) => {
257
+ const args = reloadArgsSchema.parse({ hard });
258
+ const out = await bridge.sendCommand(COMMAND.PAGE_RELOAD, args, { tabId });
259
+ return ok(out);
260
+ },
261
+ );
262
+
263
+ server.registerTool(
264
+ COMMAND.PAGE_SET_HTML,
265
+ {
266
+ description:
267
+ 'Replace the innerHTML or outerHTML of a DOM element. Use this to patch structure or content in the live page for visual debugging — changes are in-memory only and reset on reload.',
268
+ inputSchema: {
269
+ selector: selectorSchema,
270
+ html: z.string().describe('HTML string to inject.'),
271
+ target: z.enum(['innerHTML', 'outerHTML']).optional().describe(
272
+ '"innerHTML" (default) replaces inner content; "outerHTML" replaces the element itself.',
273
+ ),
274
+ tabId: tabIdParam,
275
+ },
276
+ },
277
+ async ({ selector, html, target, tabId }) => {
278
+ const args = setHtmlArgsSchema.parse({ selector, html, target });
279
+ const out = await bridge.sendCommand(COMMAND.PAGE_SET_HTML, args, { tabId });
280
+ return ok(out);
281
+ },
282
+ );
283
+
284
+ server.registerTool(
285
+ COMMAND.PAGE_SET_STYLE,
286
+ {
287
+ description:
288
+ 'Apply CSS styles to a DOM element (inline style) or inject a global <style> rule into the page. ' +
289
+ 'Use for live visual debugging — changes are in-memory only and reset on reload.',
290
+ inputSchema: {
291
+ selector: selectorSchema.optional().describe(
292
+ 'Target element for inline styles. Omit to inject a global CSS rule.',
293
+ ),
294
+ styles: z.record(z.string(), z.string()).describe(
295
+ 'For element mode: CSS property→value map, e.g. { "background": "red", "fontSize": "14px" }. ' +
296
+ 'For global mode (no selector): { "rule": ".btn { color: red; }" }.',
297
+ ),
298
+ merge: z.boolean().optional().describe(
299
+ 'Merge with existing inline styles (default true). Set false to replace all inline styles.',
300
+ ),
301
+ tabId: tabIdParam,
302
+ },
303
+ },
304
+ async ({ selector, styles, merge, tabId }) => {
305
+ const args = setStyleArgsSchema.parse({ selector, styles, merge });
306
+ const out = await bridge.sendCommand(COMMAND.PAGE_SET_STYLE, args, { tabId });
307
+ return ok(out);
308
+ },
309
+ );
310
+
311
+ server.registerTool(
312
+ COMMAND.CONSOLE_TAIL,
313
+ {
314
+ description: 'Return the last N console entries from the page.',
315
+ inputSchema: {
316
+ n: z.number().int().positive().default(20).optional(),
317
+ tabId: tabIdParam,
318
+ },
319
+ },
320
+ async ({ n, tabId }) => {
321
+ const out = await bridge.sendCommand(COMMAND.CONSOLE_TAIL, { n: n ?? 20 }, { tabId });
322
+ return ok(out);
323
+ },
324
+ );
325
+
326
+ server.registerTool(
327
+ COMMAND.NETWORK_TAIL,
328
+ {
329
+ description: 'Return the last N network requests captured by the runtime client.',
330
+ inputSchema: {
331
+ n: z.number().int().positive().default(20).optional(),
332
+ includeBody: z.boolean().optional(),
333
+ tabId: tabIdParam,
334
+ },
335
+ },
336
+ async ({ n, includeBody, tabId }) => {
337
+ const out = await bridge.sendCommand(
338
+ COMMAND.NETWORK_TAIL,
339
+ { n: n ?? 20, includeBody: includeBody ?? false },
340
+ { tabId },
341
+ );
342
+ return ok(out);
343
+ },
344
+ );
345
+
346
+ server.registerTool(
347
+ COMMAND.ERRORS_TAIL,
348
+ {
349
+ description: 'Return the last N JavaScript errors captured by the runtime client.',
350
+ inputSchema: {
351
+ n: z.number().int().positive().default(20).optional(),
352
+ tabId: tabIdParam,
353
+ },
354
+ },
355
+ async ({ n, tabId }) => {
356
+ const out = await bridge.sendCommand(COMMAND.ERRORS_TAIL, { n: n ?? 20 }, { tabId });
357
+ return ok(out);
358
+ },
359
+ );
360
+
361
+ server.registerTool(
362
+ COMMAND.TAB_LIST,
363
+ {
364
+ description: 'List all currently connected browser tabs.',
365
+ inputSchema: {},
366
+ },
367
+ async () => {
368
+ const tabs = await bridge.listTabs();
369
+ return ok(tabs);
370
+ },
371
+ );
372
+
373
+ // ─── dashboard.* tools ─────────────────────────────────────────────────
374
+
375
+ server.registerTool(
376
+ COMMAND.DASHBOARD_OPEN,
377
+ {
378
+ description:
379
+ 'Return the dev-dashboard URL for this Harness-FE daemon and, optionally, launch the user\'s default browser to it. The dashboard shows live sessions, recordings, exports, and is the primary surface a human uses to inspect what an agent is doing. Useful when the agent wants the human to look at something concrete.',
380
+ inputSchema: {
381
+ launchBrowser: z
382
+ .boolean()
383
+ .optional()
384
+ .describe(
385
+ 'When true, try to open the URL in the user\'s default browser (requires the daemon to run on the user\'s host machine — no effect in remote/Docker contexts; set HARNESS_FE_HEADLESS=1 in those environments to suppress the launch attempt).',
386
+ ),
387
+ sessionId: z
388
+ .string()
389
+ .optional()
390
+ .describe(
391
+ 'When provided, deep-link to a specific session detail page instead of the project list.',
392
+ ),
393
+ },
394
+ },
395
+ async ({ launchBrowser, sessionId }) => {
396
+ const url = buildDashboardUrl(bridge, { sessionId });
397
+ if (!url) {
398
+ return err('dashboard URL unavailable: bridge has no bound port yet');
399
+ }
400
+ let opened = false;
401
+ let reason: string | undefined;
402
+ if (launchBrowser) {
403
+ const result = openBrowser(url);
404
+ opened = result.opened;
405
+ reason = result.reason;
406
+ }
407
+ return ok({ url, opened, ...(reason ? { reason } : {}) });
408
+ },
409
+ );
410
+
411
+ // ─── project.* tools (target vite-plugin) ─────────────────────────────
412
+
413
+ server.registerTool(
414
+ COMMAND.PROJECT_SOURCE,
415
+ {
416
+ description:
417
+ "Read source code for a file or for a component. Specify exactly one of `file` (project-relative path) or `component` (PascalCase name discovered by the AST scan).",
418
+ inputSchema: {
419
+ file: z.string().optional(),
420
+ component: z.string().optional(),
421
+ projectId: z.string().optional(),
422
+ },
423
+ },
424
+ async ({ file, component, projectId }) => {
425
+ const out = await bridge.sendCommand(
426
+ COMMAND.PROJECT_SOURCE,
427
+ { file, component },
428
+ { target: 'vite-plugin', projectId },
429
+ );
430
+ return ok(out);
431
+ },
432
+ );
433
+
434
+ server.registerTool(
435
+ COMMAND.PROJECT_WHERE_IS,
436
+ {
437
+ description: 'Return file:line:col for a given component name.',
438
+ inputSchema: {
439
+ component: z.string(),
440
+ projectId: z.string().optional(),
441
+ },
442
+ },
443
+ async ({ component, projectId }) => {
444
+ const out = await bridge.sendCommand(
445
+ COMMAND.PROJECT_WHERE_IS,
446
+ { component },
447
+ { target: 'vite-plugin', projectId },
448
+ );
449
+ return ok(out);
450
+ },
451
+ );
452
+
453
+ server.registerTool(
454
+ COMMAND.PROJECT_MODULE_GRAPH,
455
+ {
456
+ description: 'Return the component map discovered by the AST scan.',
457
+ inputSchema: {
458
+ projectId: z.string().optional(),
459
+ },
460
+ },
461
+ async ({ projectId }) => {
462
+ const out = await bridge.sendCommand(
463
+ COMMAND.PROJECT_MODULE_GRAPH,
464
+ {},
465
+ { target: 'vite-plugin', projectId },
466
+ );
467
+ return ok(out);
468
+ },
469
+ );
470
+
471
+ // ─── tasks.* tools (user annotations submitted from page) ─────────────
472
+
473
+ const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
474
+
475
+ server.registerTool(
476
+ COMMAND.TASKS_PENDING,
477
+ {
478
+ description:
479
+ 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
480
+ inputSchema: {
481
+ status: taskStatusEnum.optional(),
482
+ limit: z.number().int().positive().optional(),
483
+ },
484
+ },
485
+ async ({ status, limit }) => {
486
+ const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
487
+ const summary = tasks.map((t) => ({
488
+ id: t.id,
489
+ status: t.status,
490
+ question: t.question,
491
+ selector: t.selector,
492
+ url: t.url,
493
+ tabId: t.tabId,
494
+ createdAt: t.createdAt,
495
+ claimedAt: t.claimedAt,
496
+ resolvedAt: t.resolvedAt,
497
+ note: t.note,
498
+ }));
499
+ return ok({ count: summary.length, tasks: summary });
500
+ },
501
+ );
502
+
503
+ server.registerTool(
504
+ COMMAND.TASKS_CLAIM,
505
+ {
506
+ description:
507
+ 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
508
+ inputSchema: {
509
+ taskId: z.string(),
510
+ },
511
+ },
512
+ async ({ taskId }) => {
513
+ const task = await bridge.claimTask(taskId);
514
+ if (!task) {
515
+ throw new Error(`tasks.claim: no task with id "${taskId}"`);
516
+ }
517
+ return ok(task);
518
+ },
519
+ );
520
+
521
+ server.registerTool(
522
+ COMMAND.TASKS_RESOLVE,
523
+ {
524
+ description:
525
+ 'Mark a task as resolved with an optional note. Use after addressing the user request.',
526
+ inputSchema: {
527
+ taskId: z.string(),
528
+ note: z.string().optional(),
529
+ },
530
+ },
531
+ async ({ taskId, note }) => {
532
+ const task = await bridge.resolveTask(taskId, note);
533
+ if (!task) {
534
+ throw new Error(`tasks.resolve: no task with id "${taskId}"`);
535
+ }
536
+ return ok({ ok: true, task });
537
+ },
538
+ );
539
+
540
+ server.registerTool(
541
+ 'tasks.get_attachment',
542
+ {
543
+ description:
544
+ 'Return a task screenshot attachment as a vision-ready image block. ' +
545
+ 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
546
+ 'Compatible with Claude vision and GPT-4V.',
547
+ inputSchema: {
548
+ taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
549
+ attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
550
+ },
551
+ },
552
+ async ({ taskId, attachmentId }) => {
553
+ const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
554
+ if (!base64) {
555
+ throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
556
+ }
557
+ return {
558
+ content: [
559
+ {
560
+ type: 'image' as const,
561
+ mimeType: 'image/png' as const,
562
+ data: base64,
563
+ },
564
+ ],
565
+ };
566
+ },
567
+ );
568
+ }
569
+
570
+ // ─── Store tools (session history, timeline, memory) ──────────────────────────
571
+
572
+ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge): void {
573
+ server.registerTool(
574
+ 'session.list',
575
+ {
576
+ description: 'List recent sessions for a project. Returns session IDs, start times, and status.',
577
+ inputSchema: {
578
+ projectId: z.string().describe('Project ID (package.json name)'),
579
+ limit: z.number().int().positive().default(10).optional(),
580
+ },
581
+ },
582
+ async ({ projectId, limit }) => {
583
+ const sessions = store.listSessions({ projectId, limit: limit ?? 10 });
584
+ return ok(sessions);
585
+ },
586
+ );
587
+
588
+ server.registerTool(
589
+ 'session.summary',
590
+ {
591
+ description: 'Get a summary of a session: event counts, last error, active tabs.',
592
+ inputSchema: {
593
+ sessionId: z.string().describe('Session ID from session.list'),
594
+ },
595
+ },
596
+ async ({ sessionId }) => {
597
+ const summary = store.summary(sessionId);
598
+ return ok(summary);
599
+ },
600
+ );
601
+
602
+ server.registerTool(
603
+ 'session.tail',
604
+ {
605
+ description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
606
+ inputSchema: {
607
+ sessionId: z.string(),
608
+ n: z.number().int().positive().default(50).optional(),
609
+ type: z.union([z.string(), z.array(z.string())]).optional()
610
+ .describe('Filter by event type(s): log, err, req, res, cmd, resp, hmr, task, node:log, node:err'),
611
+ projectId: z.string().optional().describe('Filter events by projectId (useful for multi-project sessions)'),
612
+ since: z.number().optional().describe('Only events after this Unix timestamp (ms)'),
613
+ until: z.number().optional().describe('Only events before this Unix timestamp (ms)'),
614
+ },
615
+ },
616
+ async ({ sessionId, n, type, projectId, since, until }) => {
617
+ try {
618
+ const session = store.getSession(sessionId);
619
+ if (!session) {
620
+ return ok({ error: 'session not found', sessionId });
621
+ }
622
+ const events = store.tail(sessionId, {
623
+ n: n ?? 50,
624
+ type: type as string | string[] | undefined,
625
+ since,
626
+ until,
627
+ projectId,
628
+ });
629
+ return ok(events);
630
+ } catch {
631
+ return ok({ error: 'session not found', sessionId });
632
+ }
633
+ },
634
+ );
635
+
636
+ server.registerTool(
637
+ 'session.search',
638
+ {
639
+ description: 'Search events in a session timeline by substring match.',
640
+ inputSchema: {
641
+ sessionId: z.string(),
642
+ query: z.string().describe('Substring to search for in event payloads'),
643
+ type: z.union([z.string(), z.array(z.string())]).optional(),
644
+ limit: z.number().int().positive().default(50).optional(),
645
+ },
646
+ },
647
+ async ({ sessionId, query, type, limit }) => {
648
+ try {
649
+ const session = store.getSession(sessionId);
650
+ if (!session) {
651
+ return ok({ error: 'session not found', sessionId });
652
+ }
653
+ const events = store.search(sessionId, query, {
654
+ type: type as string | string[] | undefined,
655
+ limit: limit ?? 50,
656
+ });
657
+ return ok(events);
658
+ } catch {
659
+ return ok({ error: 'session not found', sessionId });
660
+ }
661
+ },
662
+ );
663
+
664
+ server.registerTool(
665
+ 'project.sessions',
666
+ {
667
+ description: 'List all projects with their most recent session info.',
668
+ inputSchema: {},
669
+ },
670
+ async () => {
671
+ const projects = store.listProjects();
672
+ const result = projects.map((p) => ({
673
+ ...p,
674
+ recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
675
+ }));
676
+ return ok(result);
677
+ },
678
+ );
679
+
680
+ // ── v0.2: Project tree & build metadata (micro-frontend support) ────
681
+
682
+ server.registerTool(
683
+ 'project.list',
684
+ {
685
+ description:
686
+ 'List every project the daemon has ever seen. Returns full ProjectMeta (id, displayName, parentProjectId, tags, lastActiveAt). Use this instead of project.sessions when you only need project metadata, not session history.',
687
+ inputSchema: {},
688
+ },
689
+ async () => ok(store.listProjects()),
690
+ );
691
+
692
+ server.registerTool(
693
+ 'project.get',
694
+ {
695
+ description:
696
+ 'Read a single project\'s metadata (parentProjectId, displayName, tags, …).',
697
+ inputSchema: { projectId: z.string() },
698
+ },
699
+ async ({ projectId }) => {
700
+ const meta = store.getProject(projectId);
701
+ return meta ? ok(meta) : err(`project not found: ${projectId}`);
702
+ },
703
+ );
704
+
705
+ server.registerTool(
706
+ 'project.tree',
707
+ {
708
+ description:
709
+ 'Get the project forest assembled from parentProjectId relationships. Pass `rootId` to scope to one sub-tree. Useful for micro-frontend setups (parent app + iframe children) where you want to see all related projects at a glance.',
710
+ inputSchema: { rootId: z.string().optional() },
711
+ },
712
+ async ({ rootId }) => ok(store.getProjectTree(rootId)),
713
+ );
714
+
715
+ server.registerTool(
716
+ 'project.set_parent',
717
+ {
718
+ description:
719
+ 'Set or clear a project\'s parentProjectId. Rejects cycles (A→B→A). Pass `parentProjectId: null` to make the project a forest root.',
720
+ inputSchema: {
721
+ projectId: z.string(),
722
+ parentProjectId: z.string().nullable().optional(),
723
+ },
724
+ },
725
+ async ({ projectId, parentProjectId }) => {
726
+ try {
727
+ const meta = store.upsertProject(projectId, {
728
+ parentProjectId: parentProjectId ?? undefined,
729
+ });
730
+ return ok(meta);
731
+ } catch (e) {
732
+ return err(e instanceof Error ? e.message : String(e));
733
+ }
734
+ },
735
+ );
736
+
737
+ server.registerTool(
738
+ 'build.list',
739
+ {
740
+ description:
741
+ 'List builds recorded for a project, newest first. A build = one source-code snapshot (stable across HMR; changes on dev-server restart or prod build).',
742
+ inputSchema: { projectId: z.string(), limit: z.number().int().positive().optional() },
743
+ },
744
+ async ({ projectId, limit }) => ok(store.listBuilds(projectId, limit)),
745
+ );
746
+
747
+ server.registerTool(
748
+ 'build.get',
749
+ {
750
+ description: 'Read a single build\'s metadata (gitSha, dirty, bundler, …).',
751
+ inputSchema: { projectId: z.string(), buildId: z.string() },
752
+ },
753
+ async ({ projectId, buildId }) => {
754
+ const meta = store.getBuild(projectId, buildId);
755
+ return meta ? ok(meta) : err(`build not found: ${projectId}/${buildId}`);
756
+ },
757
+ );
758
+
759
+ // ─── visitor.* tools — investigate user identity & journey (0.5+) ────
760
+
761
+ server.registerTool(
762
+ 'visitor.list',
763
+ {
764
+ description:
765
+ 'List known visitors (anonymous browsers + optional app-supplied userId). Newest activity first. Filter by projectId to scope to one app.',
766
+ inputSchema: {
767
+ projectId: z.string().optional(),
768
+ limit: z.number().int().positive().optional(),
769
+ },
770
+ },
771
+ async ({ projectId, limit }) => ok(store.listVisitors({ projectId, limit })),
772
+ );
773
+
774
+ server.registerTool(
775
+ 'visitor.get',
776
+ {
777
+ description:
778
+ 'Read a single visitor\'s metadata: firstSeenAt, lastSeenAt, sessionCount, projectIds, tabIds, and lastEnv (UA / viewport / timezone / colorScheme).',
779
+ inputSchema: { visitorId: z.string() },
780
+ },
781
+ async ({ visitorId }) => {
782
+ const meta = store.getVisitor(visitorId);
783
+ return meta ? ok(meta) : err(`visitor not found: ${visitorId}`);
784
+ },
785
+ );
786
+
787
+ server.registerTool(
788
+ 'visitor.journey',
789
+ {
790
+ description:
791
+ 'Chronological journey for one visitor — list of sessions (pageloads) with their URL, project participants, and start/end timestamps. Newest first. Answers "what did this user actually do?"',
792
+ inputSchema: {
793
+ visitorId: z.string(),
794
+ limit: z.number().int().positive().optional(),
795
+ },
796
+ },
797
+ async ({ visitorId, limit }) => {
798
+ // Walk all sessions whose participants reference any project this
799
+ // visitor has touched, then filter by visitorId-tagged events in
800
+ // the timeline. Simpler initial impl: visitor.projectIds gives us
801
+ // the projects of interest; we list those projects' sessions and
802
+ // intersect with any session that contains an event tagged with
803
+ // this visitorId.
804
+ const visitor = store.getVisitor(visitorId);
805
+ if (!visitor) return err(`visitor not found: ${visitorId}`);
806
+ const seen = new Set<string>();
807
+ const sessionsOut: Array<{
808
+ sessionId: string;
809
+ url?: string;
810
+ title?: string;
811
+ startedAt: number;
812
+ endedAt?: number;
813
+ projects: string[];
814
+ builds: string[];
815
+ }> = [];
816
+ for (const pid of visitor.projectIds) {
817
+ for (const sess of store.listSessions({ projectId: pid, limit: 200 })) {
818
+ if (seen.has(sess.id)) continue;
819
+ // Cheap proxy: a session with this visitor's tabIds counts.
820
+ // Better filter is row-level visitorId tag — but tab match
821
+ // is usually sufficient since tabIds are visitor-owned.
822
+ if (sess.tabId && !visitor.tabIds.includes(sess.tabId)) continue;
823
+ seen.add(sess.id);
824
+ sessionsOut.push({
825
+ sessionId: sess.id,
826
+ url: sess.url,
827
+ title: sess.title,
828
+ startedAt: sess.startedAt,
829
+ endedAt: sess.endedAt,
830
+ projects: sess.participants.map((p) => p.projectId),
831
+ builds: sess.participants
832
+ .map((p) => p.buildId)
833
+ .filter((b): b is string => !!b),
834
+ });
835
+ }
836
+ }
837
+ sessionsOut.sort((a, b) => b.startedAt - a.startedAt);
838
+ const slice = limit ? sessionsOut.slice(0, limit) : sessionsOut;
839
+ return ok({ visitor, sessions: slice });
840
+ },
841
+ );
842
+
843
+ server.registerTool(
844
+ 'session.recordings.list',
845
+ {
846
+ description: 'List rrweb recording chunks available for a session.',
847
+ inputSchema: {
848
+ sessionId: z.string(),
849
+ },
850
+ },
851
+ async ({ sessionId }) => {
852
+ const session = store.getSession(sessionId);
853
+ if (!session) {
854
+ return ok({ error: 'session not found', sessionId });
855
+ }
856
+ const chunks = store.listRecordings(sessionId);
857
+ return ok({ chunks, intervals: meltRecordingIntervals(chunks) });
858
+ },
859
+ );
860
+
861
+ server.registerTool(
862
+ 'session.recordings.around',
863
+ {
864
+ description: 'Find rrweb recording chunks overlapping a window around a timestamp.',
865
+ inputSchema: {
866
+ sessionId: z.string(),
867
+ ts: z.number().describe('Center timestamp in Unix ms'),
868
+ windowMs: z.number().int().positive().default(15_000).optional(),
869
+ },
870
+ },
871
+ async ({ sessionId, ts, windowMs }) => {
872
+ const session = store.getSession(sessionId);
873
+ if (!session) {
874
+ return ok({ error: 'session not found', sessionId });
875
+ }
876
+ const radius = windowMs ?? 15_000;
877
+ const since = ts - radius;
878
+ const until = ts + radius;
879
+ const chunks = store.listRecordings(sessionId)
880
+ .filter((chunk) => chunk.endTs >= since && chunk.startTs <= until);
881
+ const markers = store.tail(sessionId, { n: 200, type: 'rrweb:marker', since, until })
882
+ .filter((marker) => chunks.some((chunk) => chunk.endTs >= marker.ts && chunk.startTs <= marker.ts));
883
+ return ok({ since, until, chunks, intervals: meltRecordingIntervals(chunks), markers });
884
+ },
885
+ );
886
+
887
+ server.registerTool(
888
+ 'session.recordings.slice',
889
+ {
890
+ description: 'Return rrweb recording chunks overlapping a requested time window.',
891
+ inputSchema: {
892
+ sessionId: z.string(),
893
+ since: z.number().describe('Start timestamp in Unix ms'),
894
+ until: z.number().describe('End timestamp in Unix ms'),
895
+ },
896
+ },
897
+ async ({ sessionId, since, until }) => {
898
+ const session = store.getSession(sessionId);
899
+ if (!session) {
900
+ return ok({ error: 'session not found', sessionId });
901
+ }
902
+ const chunks = store.sliceRecordings(sessionId, since, until);
903
+ return ok({ since, until, chunks, intervals: meltRecordingIntervals(chunks) });
904
+ },
905
+ );
906
+
907
+ server.registerTool(
908
+ 'session.replay.create',
909
+ {
910
+ description:
911
+ 'Bundle rrweb recording chunks in a time window into a single replay export and return a viewer URL. '
912
+ + 'Provide either {ts, windowMs?} (default ±15s around ts) or {since, until} explicit bounds. '
913
+ + 'The returned viewerUrl opens a self-contained rrweb-player page — paste it to the user to share the replay.',
914
+ inputSchema: {
915
+ sessionId: z.string(),
916
+ tabId: z.string().optional().describe('Restrict to a single tab (recommended for clean replay).'),
917
+ ts: z.number().optional().describe('Center timestamp in Unix ms; ignored if since/until provided.'),
918
+ windowMs: z.number().int().positive().default(15_000).optional().describe('Half-window around ts. Default 15s.'),
919
+ since: z.number().optional().describe('Explicit window start (Unix ms). Overrides ts.'),
920
+ until: z.number().optional().describe('Explicit window end (Unix ms). Overrides ts.'),
921
+ label: z.string().optional().describe('Optional human label saved with the export.'),
922
+ },
923
+ },
924
+ async ({ sessionId, tabId, ts, windowMs, since, until, label }) => {
925
+ const result = createReplayExport(store, bridge.getViewerBaseUrl(), {
926
+ sessionId, tabId, ts, windowMs, since, until, label,
927
+ });
928
+ return ok(result);
929
+ },
930
+ );
931
+
932
+ server.registerTool(
933
+ 'project.memory.set',
934
+ {
935
+ description: 'Write or update a persistent memory entry for a project (cross-session knowledge for the agent).',
936
+ inputSchema: {
937
+ projectId: z.string(),
938
+ key: z.string().min(1).describe('Memory key, e.g. "known_issues", "architecture", "agent_context"'),
939
+ value: z.string().describe('Memory value (plain text or JSON string)'),
940
+ },
941
+ },
942
+ async ({ projectId, key, value }) => {
943
+ const entry = memoryStore.set(projectId, key, value);
944
+ return ok({ ok: true, key: entry.key, updatedAt: entry.updatedAt });
945
+ },
946
+ );
947
+
948
+ server.registerTool(
949
+ 'project.memory.get',
950
+ {
951
+ description: 'Read a persistent memory entry for a project by key.',
952
+ inputSchema: {
953
+ projectId: z.string(),
954
+ key: z.string().describe('Memory key to retrieve'),
955
+ },
956
+ },
957
+ async ({ projectId, key }) => {
958
+ const entry = memoryStore.get(projectId, key);
959
+ if (!entry) {
960
+ return ok({ found: false, key });
961
+ }
962
+ return ok({ found: true, key: entry.key, value: entry.value, updatedAt: entry.updatedAt });
963
+ },
964
+ );
965
+
966
+ server.registerTool(
967
+ 'project.memory.list',
968
+ {
969
+ description: 'List all persistent memory entries for a project, sorted by most recently updated.',
970
+ inputSchema: {
971
+ projectId: z.string(),
972
+ },
973
+ },
974
+ async ({ projectId }) => {
975
+ const entries = memoryStore.list(projectId);
976
+ return ok(entries);
977
+ },
978
+ );
979
+
980
+ server.registerTool(
981
+ 'project.memory.delete',
982
+ {
983
+ description: 'Delete a persistent memory entry for a project by key.',
984
+ inputSchema: {
985
+ projectId: z.string(),
986
+ key: z.string().describe('Memory key to delete'),
987
+ },
988
+ },
989
+ async ({ projectId, key }) => {
990
+ const deleted = memoryStore.delete(projectId, key);
991
+ return ok({ deleted, key });
992
+ },
993
+ );
994
+
995
+ server.registerTool(
996
+ 'session.purge',
997
+ {
998
+ description: 'Delete old sessions and recordings to free disk space.',
999
+ inputSchema: {
1000
+ maxAgeDays: z.number().int().positive().default(7).optional(),
1001
+ maxSessionsPerProject: z.number().int().positive().default(20).optional(),
1002
+ recordingRetentionDays: z.number().int().positive().default(3).optional(),
1003
+ maxRecordingChunksPerTab: z.number().int().positive().optional(),
1004
+ maxRecordingBytesPerTab: z.number().int().positive().optional(),
1005
+ preserveMarkedChunks: z.boolean().optional(),
1006
+ },
1007
+ },
1008
+ async ({
1009
+ maxAgeDays,
1010
+ maxSessionsPerProject,
1011
+ recordingRetentionDays,
1012
+ maxRecordingChunksPerTab,
1013
+ maxRecordingBytesPerTab,
1014
+ preserveMarkedChunks,
1015
+ }) => {
1016
+ const result = store.purge({
1017
+ maxAgeDays,
1018
+ maxSessionsPerProject,
1019
+ recordingRetentionDays,
1020
+ maxRecordingChunksPerTab,
1021
+ maxRecordingBytesPerTab,
1022
+ preserveMarkedChunks,
1023
+ });
1024
+ return ok({
1025
+ sessionsDeleted: result.sessionsDeleted,
1026
+ recordingsDeleted: result.recordingsDeleted,
1027
+ bytesFreed: result.bytesFreed,
1028
+ });
1029
+ },
1030
+ );
1031
+ }
1032
+
1033
+ // ─── Remote store tools (follower mode) ───────────────────────────────────────
1034
+ //
1035
+ // When running as a follower, store/memory operations are proxied to the leader
1036
+ // via the mcp.call channel. The async variants on RemoteStore / RemoteMemoryStore
1037
+ // are used directly inside the tool handlers.
1038
+
1039
+ function registerRemoteStoreTools(server: McpServer, bridge: RemoteBridge): void {
1040
+ const remoteStore = bridge.getStore() as ReturnType<RemoteBridge['getStore']> & {
1041
+ listProjectsAsync(): Promise<unknown>;
1042
+ listSessionsAsync(opts?: { projectId?: string; tabId?: string; buildId?: string; limit?: number }): Promise<unknown>;
1043
+ summaryAsync(sessionId: string): Promise<unknown>;
1044
+ tailAsync(sessionId: string, opts?: unknown): Promise<unknown>;
1045
+ searchAsync(sessionId: string, query: string, opts?: unknown): Promise<unknown>;
1046
+ listRecordingsAsync(sessionId: string): Promise<unknown>;
1047
+ sliceRecordingsAsync(sessionId: string, since: number, until: number): Promise<unknown>;
1048
+ replayCreateAsync(args: unknown): Promise<unknown>;
1049
+ purgeAsync(policy?: unknown): Promise<unknown>;
1050
+ };
1051
+ const remoteMem = bridge.getMemoryStore() as ReturnType<RemoteBridge['getMemoryStore']> & {
1052
+ setAsync(projectId: string, key: string, value: string): Promise<unknown>;
1053
+ getAsync(projectId: string, key: string): Promise<unknown>;
1054
+ listAsync(projectId: string): Promise<unknown>;
1055
+ deleteAsync(projectId: string, key: string): Promise<unknown>;
1056
+ };
1057
+
1058
+ server.registerTool(
1059
+ 'session.list',
1060
+ {
1061
+ description: 'List recent sessions for a project. Returns session IDs, start times, and status.',
1062
+ inputSchema: {
1063
+ projectId: z.string().describe('Project ID (package.json name)'),
1064
+ limit: z.number().int().positive().default(10).optional(),
1065
+ },
1066
+ },
1067
+ async ({ projectId, limit }) => {
1068
+ const sessions = await remoteStore.listSessionsAsync({ projectId, limit: limit ?? 10 });
1069
+ return ok(sessions);
1070
+ },
1071
+ );
1072
+
1073
+ server.registerTool(
1074
+ 'session.summary',
1075
+ {
1076
+ description: 'Get a summary of a session: event counts, last error, active tabs.',
1077
+ inputSchema: {
1078
+ sessionId: z.string().describe('Session ID from session.list'),
1079
+ },
1080
+ },
1081
+ async ({ sessionId }) => {
1082
+ const summary = await remoteStore.summaryAsync(sessionId);
1083
+ return ok(summary);
1084
+ },
1085
+ );
1086
+
1087
+ server.registerTool(
1088
+ 'session.tail',
1089
+ {
1090
+ description: 'Read the last N events from a session timeline. Optionally filter by event type or projectId.',
1091
+ inputSchema: {
1092
+ sessionId: z.string(),
1093
+ n: z.number().int().positive().default(50).optional(),
1094
+ type: z.union([z.string(), z.array(z.string())]).optional()
1095
+ .describe('Filter by event type(s): log, err, req, res, cmd, resp, hmr, task, node:log, node:err'),
1096
+ projectId: z.string().optional().describe('Filter events by projectId (useful for multi-project sessions)'),
1097
+ since: z.number().optional().describe('Only events after this Unix timestamp (ms)'),
1098
+ until: z.number().optional().describe('Only events before this Unix timestamp (ms)'),
1099
+ },
1100
+ },
1101
+ async ({ sessionId, n, type, projectId, since, until }) => {
1102
+ const events = await remoteStore.tailAsync(
1103
+ sessionId,
1104
+ { n: n ?? 50, type: type as string | string[] | undefined, projectId, since, until },
1105
+ );
1106
+ return ok(events);
1107
+ },
1108
+ );
1109
+
1110
+ server.registerTool(
1111
+ 'session.search',
1112
+ {
1113
+ description: 'Search events in a session timeline by substring match.',
1114
+ inputSchema: {
1115
+ sessionId: z.string(),
1116
+ query: z.string().describe('Substring to search for in event payloads'),
1117
+ type: z.union([z.string(), z.array(z.string())]).optional(),
1118
+ limit: z.number().int().positive().default(50).optional(),
1119
+ },
1120
+ },
1121
+ async ({ sessionId, query, type, limit }) => {
1122
+ const events = await remoteStore.searchAsync(
1123
+ sessionId,
1124
+ query,
1125
+ { type: type as string | string[] | undefined, limit: limit ?? 50 },
1126
+ );
1127
+ return ok(events);
1128
+ },
1129
+ );
1130
+
1131
+ server.registerTool(
1132
+ 'project.sessions',
1133
+ {
1134
+ description: 'List all projects with their most recent session info.',
1135
+ inputSchema: {},
1136
+ },
1137
+ async () => {
1138
+ const projects = await remoteStore.listProjectsAsync() as Array<{ id: string }>;
1139
+ const result = await Promise.all(
1140
+ projects.map(async (p) => ({
1141
+ ...p,
1142
+ recentSessions: await remoteStore.listSessionsAsync({ projectId: p.id, limit: 3 }),
1143
+ })),
1144
+ );
1145
+ return ok(result);
1146
+ },
1147
+ );
1148
+
1149
+ server.registerTool(
1150
+ 'session.recordings.list',
1151
+ {
1152
+ description: 'List rrweb recording chunks available for a session.',
1153
+ inputSchema: {
1154
+ sessionId: z.string(),
1155
+ },
1156
+ },
1157
+ async ({ sessionId }) => {
1158
+ const chunks = await remoteStore.listRecordingsAsync(sessionId);
1159
+ return ok({ chunks, intervals: meltRecordingIntervals(chunks as Array<{
1160
+ startTs: number;
1161
+ endTs: number;
1162
+ chunkId: string;
1163
+ tabId: string;
1164
+ eventCount: number;
1165
+ }>) });
1166
+ },
1167
+ );
1168
+
1169
+ server.registerTool(
1170
+ 'session.recordings.around',
1171
+ {
1172
+ description: 'Find rrweb recording chunks overlapping a window around a timestamp.',
1173
+ inputSchema: {
1174
+ sessionId: z.string(),
1175
+ ts: z.number().describe('Center timestamp in Unix ms'),
1176
+ windowMs: z.number().int().positive().default(15_000).optional(),
1177
+ },
1178
+ },
1179
+ async ({ sessionId, ts, windowMs }) => {
1180
+ const radius = windowMs ?? 15_000;
1181
+ const since = ts - radius;
1182
+ const until = ts + radius;
1183
+ const chunks = await remoteStore.listRecordingsAsync(sessionId) as Array<{
1184
+ startTs: number;
1185
+ endTs: number;
1186
+ }>;
1187
+ const markers = await remoteStore.tailAsync(
1188
+ sessionId,
1189
+ { n: 200, type: 'rrweb:marker', since, until },
1190
+ ) as Array<{ ts: number }>;
1191
+ return ok({
1192
+ since,
1193
+ until,
1194
+ chunks: chunks.filter((chunk) => chunk.endTs >= since && chunk.startTs <= until),
1195
+ intervals: meltRecordingIntervals(
1196
+ chunks.filter((chunk) => chunk.endTs >= since && chunk.startTs <= until) as Array<{
1197
+ startTs: number;
1198
+ endTs: number;
1199
+ chunkId: string;
1200
+ tabId: string;
1201
+ eventCount: number;
1202
+ }>,
1203
+ ),
1204
+ markers: markers.filter((marker) =>
1205
+ chunks.some((chunk) => chunk.endTs >= marker.ts && chunk.startTs <= marker.ts),
1206
+ ),
1207
+ });
1208
+ },
1209
+ );
1210
+
1211
+ server.registerTool(
1212
+ 'session.recordings.slice',
1213
+ {
1214
+ description: 'Return rrweb recording chunks overlapping a requested time window.',
1215
+ inputSchema: {
1216
+ sessionId: z.string(),
1217
+ since: z.number().describe('Start timestamp in Unix ms'),
1218
+ until: z.number().describe('End timestamp in Unix ms'),
1219
+ },
1220
+ },
1221
+ async ({ sessionId, since, until }) => {
1222
+ const chunks = await remoteStore.sliceRecordingsAsync(sessionId, since, until);
1223
+ return ok({
1224
+ since,
1225
+ until,
1226
+ chunks,
1227
+ intervals: meltRecordingIntervals(chunks as Array<{
1228
+ startTs: number;
1229
+ endTs: number;
1230
+ chunkId: string;
1231
+ tabId: string;
1232
+ eventCount: number;
1233
+ }>),
1234
+ });
1235
+ },
1236
+ );
1237
+
1238
+ server.registerTool(
1239
+ 'session.replay.create',
1240
+ {
1241
+ description:
1242
+ 'Bundle rrweb recording chunks in a time window into a single replay export and return a viewer URL. '
1243
+ + 'Provide either {ts, windowMs?} (default ±15s around ts) or {since, until} explicit bounds. '
1244
+ + 'The returned viewerUrl opens a self-contained rrweb-player page — paste it to the user to share the replay.',
1245
+ inputSchema: {
1246
+ sessionId: z.string(),
1247
+ tabId: z.string().optional(),
1248
+ ts: z.number().optional(),
1249
+ windowMs: z.number().int().positive().default(15_000).optional(),
1250
+ since: z.number().optional(),
1251
+ until: z.number().optional(),
1252
+ label: z.string().optional(),
1253
+ },
1254
+ },
1255
+ async ({ sessionId, tabId, ts, windowMs, since, until, label }) => {
1256
+ const result = await remoteStore.replayCreateAsync({
1257
+ sessionId, tabId, ts, windowMs, since, until, label,
1258
+ });
1259
+ return ok(result);
1260
+ },
1261
+ );
1262
+
1263
+ server.registerTool(
1264
+ 'project.memory.set',
1265
+ {
1266
+ description: 'Write or update a persistent memory entry for a project (cross-session knowledge for the agent).',
1267
+ inputSchema: {
1268
+ projectId: z.string(),
1269
+ key: z.string().min(1).describe('Memory key, e.g. "known_issues", "architecture", "agent_context"'),
1270
+ value: z.string().describe('Memory value (plain text or JSON string)'),
1271
+ },
1272
+ },
1273
+ async ({ projectId, key, value }) => {
1274
+ const entry = await remoteMem.setAsync(projectId, key, value);
1275
+ return ok(entry);
1276
+ },
1277
+ );
1278
+
1279
+ server.registerTool(
1280
+ 'project.memory.get',
1281
+ {
1282
+ description: 'Read a persistent memory entry for a project by key.',
1283
+ inputSchema: {
1284
+ projectId: z.string(),
1285
+ key: z.string().describe('Memory key to retrieve'),
1286
+ },
1287
+ },
1288
+ async ({ projectId, key }) => {
1289
+ const entry = await remoteMem.getAsync(projectId, key);
1290
+ if (!entry) {
1291
+ return ok({ found: false, key });
1292
+ }
1293
+ return ok({ found: true, ...(entry as object) });
1294
+ },
1295
+ );
1296
+
1297
+ server.registerTool(
1298
+ 'project.memory.list',
1299
+ {
1300
+ description: 'List all persistent memory entries for a project, sorted by most recently updated.',
1301
+ inputSchema: {
1302
+ projectId: z.string(),
1303
+ },
1304
+ },
1305
+ async ({ projectId }) => {
1306
+ const entries = await remoteMem.listAsync(projectId);
1307
+ return ok(entries);
1308
+ },
1309
+ );
1310
+
1311
+ server.registerTool(
1312
+ 'project.memory.delete',
1313
+ {
1314
+ description: 'Delete a persistent memory entry for a project by key.',
1315
+ inputSchema: {
1316
+ projectId: z.string(),
1317
+ key: z.string().describe('Memory key to delete'),
1318
+ },
1319
+ },
1320
+ async ({ projectId, key }) => {
1321
+ const deleted = await remoteMem.deleteAsync(projectId, key);
1322
+ return ok({ deleted, key });
1323
+ },
1324
+ );
1325
+
1326
+ server.registerTool(
1327
+ 'session.purge',
1328
+ {
1329
+ description: 'Delete old sessions and recordings to free disk space.',
1330
+ inputSchema: {
1331
+ maxAgeDays: z.number().int().positive().default(7).optional(),
1332
+ maxSessionsPerProject: z.number().int().positive().default(20).optional(),
1333
+ recordingRetentionDays: z.number().int().positive().default(3).optional(),
1334
+ maxRecordingChunksPerTab: z.number().int().positive().optional(),
1335
+ maxRecordingBytesPerTab: z.number().int().positive().optional(),
1336
+ preserveMarkedChunks: z.boolean().optional(),
1337
+ },
1338
+ },
1339
+ async ({
1340
+ maxAgeDays,
1341
+ maxSessionsPerProject,
1342
+ recordingRetentionDays,
1343
+ maxRecordingChunksPerTab,
1344
+ maxRecordingBytesPerTab,
1345
+ preserveMarkedChunks,
1346
+ }) => {
1347
+ const result = await remoteStore.purgeAsync({
1348
+ maxAgeDays,
1349
+ maxSessionsPerProject,
1350
+ recordingRetentionDays,
1351
+ maxRecordingChunksPerTab,
1352
+ maxRecordingBytesPerTab,
1353
+ preserveMarkedChunks,
1354
+ });
1355
+ return ok(result);
1356
+ },
1357
+ );
1358
+ }
1359
+
1360
+ function meltRecordingIntervals(chunks: Array<{
1361
+ chunkId: string;
1362
+ tabId: string;
1363
+ startTs: number;
1364
+ endTs: number;
1365
+ eventCount: number;
1366
+ }>): Array<{
1367
+ startTs: number;
1368
+ endTs: number;
1369
+ chunkCount: number;
1370
+ eventCount: number;
1371
+ chunkIds: string[];
1372
+ tabIds: string[];
1373
+ }> {
1374
+ if (chunks.length === 0) return [];
1375
+ const sorted = [...chunks].sort((a, b) => a.startTs - b.startTs || a.endTs - b.endTs);
1376
+ const intervals: Array<{
1377
+ startTs: number;
1378
+ endTs: number;
1379
+ chunkCount: number;
1380
+ eventCount: number;
1381
+ chunkIds: string[];
1382
+ tabIds: string[];
1383
+ }> = [];
1384
+
1385
+ for (const chunk of sorted) {
1386
+ const last = intervals[intervals.length - 1];
1387
+ if (!last || chunk.startTs > last.endTs) {
1388
+ intervals.push({
1389
+ startTs: chunk.startTs,
1390
+ endTs: chunk.endTs,
1391
+ chunkCount: 1,
1392
+ eventCount: chunk.eventCount,
1393
+ chunkIds: [chunk.chunkId],
1394
+ tabIds: [chunk.tabId],
1395
+ });
1396
+ continue;
1397
+ }
1398
+
1399
+ last.endTs = Math.max(last.endTs, chunk.endTs);
1400
+ last.chunkCount += 1;
1401
+ last.eventCount += chunk.eventCount;
1402
+ last.chunkIds.push(chunk.chunkId);
1403
+ if (!last.tabIds.includes(chunk.tabId)) last.tabIds.push(chunk.tabId);
1404
+ }
1405
+
1406
+ return intervals;
1407
+ }