browsertrack 0.2.1 → 0.2.3

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 (55) hide show
  1. package/AGENTS.md +9 -6
  2. package/README.md +2 -0
  3. package/dist/{chunk-464D4U2U.js → chunk-5NR5K3ER.js} +299 -44
  4. package/dist/chunk-5NR5K3ER.js.map +1 -0
  5. package/dist/{chunk-3HOXPTM2.js → chunk-G5CIZSQM.js} +808 -53
  6. package/dist/chunk-G5CIZSQM.js.map +1 -0
  7. package/dist/{chunk-6VA7GBAO.js → chunk-ONPW7AYL.js} +144 -12
  8. package/dist/chunk-ONPW7AYL.js.map +1 -0
  9. package/dist/{chunk-INXDWPJW.js → chunk-PG4JJDCV.js} +353 -119
  10. package/dist/chunk-PG4JJDCV.js.map +1 -0
  11. package/dist/cli/index.js +1058 -546
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/client/index.cjs +449 -128
  14. package/dist/client/index.d.ts +2 -0
  15. package/dist/client/index.js +2 -2
  16. package/dist/client.iife.js +121 -15
  17. package/dist/core/index.d.ts +32 -2
  18. package/dist/core/index.js +11 -1
  19. package/dist/daemon/index.d.ts +2 -2
  20. package/dist/daemon/index.js +6 -8
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +16 -7
  23. package/dist/mcp/index.d.ts +1 -1
  24. package/dist/mcp/index.js +7 -4
  25. package/dist/{server-DiVmTrIR.d.ts → server-BRG-RQQP.d.ts} +10 -1
  26. package/docs/cli.md +4 -1
  27. package/docs/getting-started.md +60 -6
  28. package/docs/mcp-reference.md +42 -0
  29. package/package.json +1 -1
  30. package/packages/cli/src/index.ts +247 -151
  31. package/packages/client/src/interceptors/navigation.ts +38 -26
  32. package/packages/client/src/interceptors/network.ts +22 -17
  33. package/packages/client/src/notes/inspector.ts +289 -65
  34. package/packages/client/src/source/resolver.ts +9 -3
  35. package/packages/client/src/transport/websocket.ts +23 -18
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/safety.ts +86 -0
  38. package/packages/core/src/selector.ts +110 -15
  39. package/packages/daemon/src/server/daemon.ts +7 -1
  40. package/packages/daemon/src/server/http.ts +125 -5
  41. package/packages/daemon/src/server/ws.ts +33 -29
  42. package/packages/daemon/src/storage/db.ts +57 -35
  43. package/packages/mcp/src/handlers.ts +121 -45
  44. package/packages/mcp/src/server.ts +221 -3
  45. package/test/core/safety.test.ts +106 -0
  46. package/test/core/selector.test.ts +133 -0
  47. package/test/daemon/storage.test.ts +36 -0
  48. package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
  49. package/test/mcp/auto-start.test.ts +87 -0
  50. package/dist/chunk-3HOXPTM2.js.map +0 -1
  51. package/dist/chunk-464D4U2U.js.map +0 -1
  52. package/dist/chunk-6VA7GBAO.js.map +0 -1
  53. package/dist/chunk-7OCOQGDN.js +0 -635
  54. package/dist/chunk-7OCOQGDN.js.map +0 -1
  55. package/dist/chunk-INXDWPJW.js.map +0 -1
@@ -18,6 +18,7 @@ import type {
18
18
  ScenarioOverview,
19
19
  ScenarioDetail,
20
20
  } from '../../../core/src/index.js';
21
+ import { safeJsonParse, safeJsonStringify } from '../../../core/src/index.js';
21
22
 
22
23
  export class StorageDB {
23
24
  private db: Database.Database;
@@ -29,6 +30,7 @@ export class StorageDB {
29
30
  this.db = new Database(dbPath);
30
31
  this.db.pragma('journal_mode = WAL');
31
32
  this.db.pragma('synchronous = NORMAL');
33
+ this.db.pragma('busy_timeout = 5000');
32
34
  this.initTables();
33
35
  }
34
36
 
@@ -161,18 +163,38 @@ export class StorageDB {
161
163
  CREATE INDEX IF NOT EXISTS idx_incidents_project ON incidents(project_id, status);
162
164
  CREATE INDEX IF NOT EXISTS idx_incidents_fp ON incidents(fingerprint);
163
165
  CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id, status);
164
- CREATE INDEX IF NOT EXISTS idx_notes_scenario ON notes(scenario_id, step_number);
165
166
  `);
166
167
 
167
- // Schema migrations for scenario fields
168
+ // Schema migrations for scenario fields on existing databases
168
169
  try {
169
- this.db.exec('ALTER TABLE notes ADD COLUMN scenario_id TEXT;');
170
- } catch {}
171
- try {
172
- this.db.exec('ALTER TABLE notes ADD COLUMN step_number INTEGER;');
173
- } catch {}
170
+ const columns = (this.db.prepare('PRAGMA table_info(notes)').all() as Array<{ name: string }>).map(
171
+ (c) => c.name
172
+ );
173
+ if (!columns.includes('scenario_id')) {
174
+ this.db.exec('ALTER TABLE notes ADD COLUMN scenario_id TEXT;');
175
+ }
176
+ if (!columns.includes('step_number')) {
177
+ this.db.exec('ALTER TABLE notes ADD COLUMN step_number INTEGER;');
178
+ }
179
+ if (!columns.includes('scenario_title')) {
180
+ this.db.exec('ALTER TABLE notes ADD COLUMN scenario_title TEXT;');
181
+ }
182
+ } catch {
183
+ // Fallback try-catch
184
+ try {
185
+ this.db.exec('ALTER TABLE notes ADD COLUMN scenario_id TEXT;');
186
+ } catch {}
187
+ try {
188
+ this.db.exec('ALTER TABLE notes ADD COLUMN step_number INTEGER;');
189
+ } catch {}
190
+ try {
191
+ this.db.exec('ALTER TABLE notes ADD COLUMN scenario_title TEXT;');
192
+ } catch {}
193
+ }
194
+
195
+ // Create scenario index after columns are guaranteed to exist
174
196
  try {
175
- this.db.exec('ALTER TABLE notes ADD COLUMN scenario_title TEXT;');
197
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_notes_scenario ON notes(scenario_id, step_number);');
176
198
  } catch {}
177
199
  }
178
200
 
@@ -375,7 +397,7 @@ export class StorageDB {
375
397
  id: r.id,
376
398
  sessionId: r.session_id,
377
399
  eventType: r.event_type,
378
- payload: JSON.parse(r.payload),
400
+ payload: safeJsonParse(r.payload, {}),
379
401
  timestamp: r.timestamp,
380
402
  route: r.route,
381
403
  url: r.url,
@@ -445,9 +467,9 @@ export class StorageDB {
445
467
  incident.occurrences,
446
468
  incident.status,
447
469
  incident.stack || null,
448
- JSON.stringify(incident.breadcrumbs || []),
449
- JSON.stringify(incident.networkFailures || []),
450
- incident.lastElement ? JSON.stringify(incident.lastElement) : null,
470
+ safeJsonStringify(incident.breadcrumbs || []),
471
+ safeJsonStringify(incident.networkFailures || []),
472
+ incident.lastElement ? safeJsonStringify(incident.lastElement) : null,
451
473
  incident.screenshots?.error || null
452
474
  );
453
475
  }
@@ -475,8 +497,8 @@ export class StorageDB {
475
497
  update.lastSeen,
476
498
  update.occurrences,
477
499
  update.route,
478
- JSON.stringify(update.breadcrumbs || []),
479
- update.lastElement ? JSON.stringify(update.lastElement) : null,
500
+ safeJsonStringify(update.breadcrumbs || []),
501
+ update.lastElement ? safeJsonStringify(update.lastElement) : null,
480
502
  update.stack || null,
481
503
  incidentId
482
504
  );
@@ -500,8 +522,8 @@ export class StorageDB {
500
522
  occurrence.route,
501
523
  occurrence.url,
502
524
  occurrence.stack || null,
503
- JSON.stringify(occurrence.breadcrumbs || []),
504
- occurrence.lastElement ? JSON.stringify(occurrence.lastElement) : null
525
+ safeJsonStringify(occurrence.breadcrumbs || []),
526
+ occurrence.lastElement ? safeJsonStringify(occurrence.lastElement) : null
505
527
  );
506
528
  }
507
529
 
@@ -525,7 +547,7 @@ export class StorageDB {
525
547
  v.id,
526
548
  v.incidentId,
527
549
  v.status,
528
- JSON.stringify(v.checks),
550
+ safeJsonStringify(v.checks),
529
551
  v.beforeScreenshot || null,
530
552
  v.afterScreenshot || null,
531
553
  v.message || null,
@@ -539,7 +561,7 @@ export class StorageDB {
539
561
  return {
540
562
  incidentId: row.incident_id,
541
563
  status: row.status as IncidentStatus,
542
- checks: JSON.parse(row.checks || '[]'),
564
+ checks: safeJsonParse(row.checks, []),
543
565
  screenshots: {
544
566
  before: row.before_screenshot || undefined,
545
567
  after: row.after_screenshot || undefined,
@@ -567,11 +589,11 @@ export class StorageDB {
567
589
  note.message,
568
590
  note.route,
569
591
  note.url,
570
- JSON.stringify(note.viewport),
571
- JSON.stringify(note.scroll),
572
- note.target ? JSON.stringify(note.target) : null,
573
- note.elementContext ? JSON.stringify(note.elementContext) : null,
574
- note.region ? JSON.stringify(note.region) : null,
592
+ safeJsonStringify(note.viewport),
593
+ safeJsonStringify(note.scroll),
594
+ note.target ? safeJsonStringify(note.target) : null,
595
+ note.elementContext ? safeJsonStringify(note.elementContext) : null,
596
+ note.region ? safeJsonStringify(note.region) : null,
575
597
  note.screenshots?.original || null,
576
598
  note.incidentId || null,
577
599
  note.scenarioId || null,
@@ -739,8 +761,8 @@ export class StorageDB {
739
761
  `nver_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
740
762
  v.noteId,
741
763
  v.status,
742
- JSON.stringify(v.checks),
743
- v.geometryDiff ? JSON.stringify(v.geometryDiff) : null,
764
+ safeJsonStringify(v.checks),
765
+ v.geometryDiff ? safeJsonStringify(v.geometryDiff) : null,
744
766
  v.screenshots?.before || null,
745
767
  v.screenshots?.after || null,
746
768
  v.message || null,
@@ -754,8 +776,8 @@ export class StorageDB {
754
776
  return {
755
777
  noteId: row.note_id,
756
778
  status: row.status as NoteStatus,
757
- checks: JSON.parse(row.checks || '[]'),
758
- geometryDiff: row.geometry_diff ? JSON.parse(row.geometry_diff) : undefined,
779
+ checks: safeJsonParse(row.checks, []),
780
+ geometryDiff: safeJsonParse(row.geometry_diff, undefined),
759
781
  screenshots: {
760
782
  before: row.before_screenshot || undefined,
761
783
  after: row.after_screenshot || undefined,
@@ -798,9 +820,9 @@ export class StorageDB {
798
820
  occurrences: row.occurrences,
799
821
  status: row.status as IncidentStatus,
800
822
  stack: row.stack || undefined,
801
- breadcrumbs: JSON.parse(row.breadcrumbs || '[]'),
802
- networkFailures: JSON.parse(row.network_failures || '[]'),
803
- lastElement: row.last_element ? JSON.parse(row.last_element) : undefined,
823
+ breadcrumbs: safeJsonParse(row.breadcrumbs, []),
824
+ networkFailures: safeJsonParse(row.network_failures, []),
825
+ lastElement: safeJsonParse(row.last_element, undefined),
804
826
  screenshots: row.screenshot_path
805
827
  ? {
806
828
  error: row.screenshot_path,
@@ -818,11 +840,11 @@ export class StorageDB {
818
840
  message: row.message,
819
841
  route: row.route,
820
842
  url: row.url,
821
- viewport: JSON.parse(row.viewport_json || '{}'),
822
- scroll: JSON.parse(row.scroll_json || '{}'),
823
- target: row.target_json ? JSON.parse(row.target_json) : undefined,
824
- elementContext: row.element_context_json ? JSON.parse(row.element_context_json) : undefined,
825
- region: row.region_json ? JSON.parse(row.region_json) : undefined,
843
+ viewport: safeJsonParse(row.viewport_json, { width: 0, height: 0, devicePixelRatio: 1 }),
844
+ scroll: safeJsonParse(row.scroll_json, { scrollX: 0, scrollY: 0 }),
845
+ target: safeJsonParse(row.target_json, undefined),
846
+ elementContext: safeJsonParse(row.element_context_json, undefined),
847
+ region: safeJsonParse(row.region_json, undefined),
826
848
  status: row.status as NoteStatus,
827
849
  incidentId: row.incident_id || undefined,
828
850
  scenarioId: row.scenario_id || undefined,
@@ -9,6 +9,120 @@ export interface McpContext {
9
9
  verificationEngine?: VerificationEngine;
10
10
  noteVerificationEngine?: NoteVerificationEngine;
11
11
  daemonUrl?: string;
12
+ daemonInitPromise?: Promise<void>;
13
+ }
14
+
15
+ async function sendSessionCommand(ctx: McpContext, sessionId: string | undefined, command: any, timeoutMs = 5000): Promise<any> {
16
+ if (ctx.daemonInitPromise) {
17
+ try {
18
+ await Promise.race([ctx.daemonInitPromise, new Promise((r) => setTimeout(r, 2000))]);
19
+ } catch {}
20
+ }
21
+
22
+ // 1. In-process session manager (if active sockets exist)
23
+ if (ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
24
+ const targetSession = sessionId ? ctx.db.getSession(sessionId) : ctx.sessionManager.getAnyActiveSession();
25
+ if (targetSession) {
26
+ const res = await ctx.sessionManager.sendCommand(targetSession.id, command, timeoutMs);
27
+ if (!res.ok) {
28
+ throw new Error(res.error || res.reason || `Command ${command.type} failed`);
29
+ }
30
+ return res;
31
+ }
32
+ }
33
+
34
+ // 2. Multi-process proxy to running daemon HTTP server
35
+ if (ctx.daemonUrl) {
36
+ try {
37
+ const resp = await fetch(`${ctx.daemonUrl}/api/command`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ sessionId, command, timeoutMs }),
41
+ signal: AbortSignal.timeout(timeoutMs + 2000),
42
+ });
43
+ if (resp.ok) {
44
+ const res = await resp.json();
45
+ if (!res.ok) {
46
+ throw new Error(res.error || res.reason || `Command ${command.type} failed`);
47
+ }
48
+ return res;
49
+ }
50
+ const errJson = await resp.json().catch(() => ({}));
51
+ if (errJson.error) {
52
+ throw new Error(errJson.error);
53
+ }
54
+ } catch (err: any) {
55
+ if (err.message && !err.message.includes('fetch failed') && !err.message.includes('ECONNREFUSED')) {
56
+ throw err;
57
+ }
58
+ }
59
+ }
60
+
61
+ throw new Error(
62
+ 'No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
63
+ );
64
+ }
65
+
66
+ async function runVerifyIncident(ctx: McpContext, incidentId: string, options: any): Promise<any> {
67
+ if (ctx.verificationEngine && ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
68
+ return await ctx.verificationEngine.verifyIncident(incidentId, options);
69
+ }
70
+
71
+ if (ctx.daemonUrl) {
72
+ try {
73
+ const timeoutMs = (options?.observationWindowMs || 3000) + 7000;
74
+ const resp = await fetch(`${ctx.daemonUrl}/api/verify/incident`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ incidentId, options }),
78
+ signal: AbortSignal.timeout(timeoutMs),
79
+ });
80
+ if (resp.ok) {
81
+ const data = await resp.json();
82
+ if (data.ok) return data.result;
83
+ throw new Error(data.error);
84
+ }
85
+ } catch (err: any) {
86
+ if (err.message && !err.message.includes('fetch failed') && !err.message.includes('ECONNREFUSED')) {
87
+ throw err;
88
+ }
89
+ }
90
+ }
91
+
92
+ throw new Error(
93
+ 'Verification failed: No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
94
+ );
95
+ }
96
+
97
+ async function runVerifyNote(ctx: McpContext, noteId: string, options: any): Promise<any> {
98
+ if (ctx.noteVerificationEngine && ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
99
+ return await ctx.noteVerificationEngine.verifyNote(noteId, options);
100
+ }
101
+
102
+ if (ctx.daemonUrl) {
103
+ try {
104
+ const timeoutMs = (options?.observationWindowMs || 3000) + 7000;
105
+ const resp = await fetch(`${ctx.daemonUrl}/api/verify/note`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ noteId, options }),
109
+ signal: AbortSignal.timeout(timeoutMs),
110
+ });
111
+ if (resp.ok) {
112
+ const data = await resp.json();
113
+ if (data.ok) return data.result;
114
+ throw new Error(data.error);
115
+ }
116
+ } catch (err: any) {
117
+ if (err.message && !err.message.includes('fetch failed') && !err.message.includes('ECONNREFUSED')) {
118
+ throw err;
119
+ }
120
+ }
121
+ }
122
+
123
+ throw new Error(
124
+ 'Verification failed: No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
125
+ );
12
126
  }
13
127
 
14
128
  export async function handleToolCall(name: string, args: any, ctx: McpContext): Promise<any> {
@@ -181,43 +295,20 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
181
295
  }
182
296
 
183
297
  case 'get_page_state': {
184
- if (!sessionManager) {
185
- throw new Error('Live browser connection not available: Daemon session manager not attached.');
186
- }
187
- let session = args.sessionId ? db.getSession(args.sessionId) : sessionManager.getAnyActiveSession();
188
- if (!session) {
189
- throw new Error('No active browser session connected.');
190
- }
191
-
192
- const cmdRes = await sessionManager.sendCommand(session.id, {
298
+ const cmdRes = await sendSessionCommand(ctx, args.sessionId, {
193
299
  id: `cmd_mcp_${Date.now()}`,
194
300
  type: 'get_page_state',
195
301
  });
196
-
197
- if (!cmdRes.ok) {
198
- throw new Error(cmdRes.error || 'Failed to retrieve page state from browser.');
199
- }
200
302
  return cmdRes.result;
201
303
  }
202
304
 
203
305
  case 'capture_element': {
204
- if (!sessionManager) {
205
- throw new Error('Live browser connection not available: Daemon session manager not attached.');
206
- }
207
- let session = args.sessionId ? db.getSession(args.sessionId) : sessionManager.getAnyActiveSession();
208
- if (!session) {
209
- throw new Error('No active browser session connected.');
210
- }
211
-
212
- const cmdRes = await sessionManager.sendCommand(session.id, {
306
+ const cmdRes = await sendSessionCommand(ctx, args.sessionId, {
213
307
  id: `cmd_mcp_${Date.now()}`,
214
308
  type: 'capture_element',
215
309
  params: { selector: args.selector },
216
310
  });
217
311
 
218
- if (!cmdRes.ok) {
219
- throw new Error(cmdRes.error || cmdRes.reason || 'Failed to capture element screenshot.');
220
- }
221
312
  return {
222
313
  ok: true,
223
314
  format: cmdRes.result?.format || 'webp',
@@ -228,11 +319,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
228
319
  }
229
320
 
230
321
  case 'verify_incident': {
231
- if (!verificationEngine) {
232
- throw new Error('Verification engine not available: Daemon session manager not attached.');
233
- }
234
-
235
- const res = await verificationEngine.verifyIncident(args.incidentId, {
322
+ const res = await runVerifyIncident(ctx, args.incidentId, {
236
323
  route: args.route,
237
324
  targetSelector: args.targetSelector,
238
325
  expect: args.expect,
@@ -399,10 +486,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
399
486
  }
400
487
 
401
488
  case 'verify_note': {
402
- if (!noteVerificationEngine) {
403
- throw new Error('Note verification engine not available: Daemon session manager not attached.');
404
- }
405
- const res = await noteVerificationEngine.verifyNote(args.noteId, {
489
+ const res = await runVerifyNote(ctx, args.noteId, {
406
490
  observationWindowMs: args.observationWindowMs,
407
491
  });
408
492
  return res;
@@ -417,27 +501,19 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
417
501
  }
418
502
 
419
503
  case 'capture_note_context': {
420
- if (!sessionManager) {
421
- throw new Error('Live browser connection not available: Daemon session manager not attached.');
422
- }
423
- let session = args.sessionId ? db.getSession(args.sessionId) : sessionManager.getAnyActiveSession();
424
- if (!session) {
425
- throw new Error('No active browser session connected.');
426
- }
427
-
428
- const queryCmd = await sessionManager.sendCommand(session.id, {
504
+ const queryCmd = await sendSessionCommand(ctx, args.sessionId, {
429
505
  id: `cmd_ctx_${Date.now()}`,
430
506
  type: 'query_element',
431
507
  params: { selector: args.selector },
432
508
  });
433
509
 
434
- const overflowCmd = await sessionManager.sendCommand(session.id, {
510
+ const overflowCmd = await sendSessionCommand(ctx, args.sessionId, {
435
511
  id: `cmd_ovf_${Date.now()}`,
436
512
  type: 'check_overflow',
437
513
  params: { selector: args.selector },
438
514
  });
439
515
 
440
- const styleCmd = await sessionManager.sendCommand(session.id, {
516
+ const styleCmd = await sendSessionCommand(ctx, args.sessionId, {
441
517
  id: `cmd_sty_${Date.now()}`,
442
518
  type: 'get_element_style',
443
519
  params: { selector: args.selector },
@@ -1,3 +1,7 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
1
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
@@ -7,18 +11,98 @@ import { ScreenshotStore } from '../../daemon/src/storage/screenshot-store.js';
7
11
  import { SessionManager } from '../../daemon/src/session/manager.js';
8
12
  import { NotesEngine } from '../../daemon/src/notes/engine.js';
9
13
  import { NoteVerificationEngine } from '../../daemon/src/notes/verification.js';
10
- import { VerificationEngine } from '../../daemon/src/verification/engine.js';
14
+ import { VerificationEngine, createDaemon } from '../../daemon/src/index.js';
11
15
  import type { McpContext } from './handlers.js';
12
16
  import { handleToolCall } from './handlers.js';
13
17
  import { TOOLS } from './tools.js';
14
18
 
19
+ export async function isDaemonRunning(host: string, port: number): Promise<boolean> {
20
+ try {
21
+ const res = await fetch(`http://${host}:${port}/health`, {
22
+ signal: AbortSignal.timeout(600),
23
+ });
24
+ if (res.ok) {
25
+ const data = (await res.json().catch(() => ({}))) as any;
26
+ return data?.name === 'browsertrack';
27
+ }
28
+ } catch {}
29
+ return false;
30
+ }
31
+
32
+ function resolveCliPath(): string | null {
33
+ if (process.argv[1]) {
34
+ const candidate = process.argv[1];
35
+ if (
36
+ candidate.endsWith('cli/index.js') ||
37
+ candidate.endsWith('browsertrack') ||
38
+ candidate.endsWith('bin/browsertrack.js') ||
39
+ candidate.endsWith('dist/cli/index.js')
40
+ ) {
41
+ if (fs.existsSync(candidate)) return candidate;
42
+ }
43
+ }
44
+
45
+ try {
46
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
47
+ const paths = [
48
+ path.resolve(currentDir, '../cli/index.js'),
49
+ path.resolve(currentDir, '../../cli/index.js'),
50
+ path.resolve(currentDir, '../../dist/cli/index.js'),
51
+ ];
52
+ for (const p of paths) {
53
+ if (fs.existsSync(p)) return p;
54
+ }
55
+ } catch {}
56
+
57
+ return null;
58
+ }
59
+
60
+ function acquireBootLock(lockFile: string): boolean {
61
+ try {
62
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
63
+ const fd = fs.openSync(lockFile, 'wx');
64
+ fs.writeSync(fd, String(process.pid));
65
+ fs.closeSync(fd);
66
+ return true;
67
+ } catch {
68
+ try {
69
+ const stats = fs.statSync(lockFile);
70
+ // If older than 5 seconds, assume stale lock and break it
71
+ if (Date.now() - stats.mtimeMs > 5000) {
72
+ fs.unlinkSync(lockFile);
73
+ const fd = fs.openSync(lockFile, 'wx');
74
+ fs.writeSync(fd, String(process.pid));
75
+ fs.closeSync(fd);
76
+ return true;
77
+ }
78
+ } catch {}
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function releaseBootLock(lockFile: string): void {
84
+ try {
85
+ if (fs.existsSync(lockFile)) {
86
+ fs.unlinkSync(lockFile);
87
+ }
88
+ } catch {}
89
+ }
90
+
15
91
  export interface McpServerOptions {
16
92
  dbPath?: string;
17
93
  context?: Partial<McpContext>;
94
+ autoStartDaemon?: boolean;
95
+ port?: number;
96
+ host?: string;
97
+ detached?: boolean;
18
98
  }
19
99
 
20
100
  export function createMcpServer(options: McpServerOptions = {}) {
21
- const config = getDaemonConfig({ dbPath: options.dbPath });
101
+ const config = getDaemonConfig({
102
+ dbPath: options.dbPath,
103
+ port: options.port,
104
+ host: options.host,
105
+ });
22
106
  const db = options.context?.db || new StorageDB(config.dbPath);
23
107
  const screenshotStore = new ScreenshotStore(config.screenshotsDir);
24
108
  const sessionManager = options.context?.sessionManager || new SessionManager(db);
@@ -35,6 +119,121 @@ export function createMcpServer(options: McpServerOptions = {}) {
35
119
  ...options.context,
36
120
  };
37
121
 
122
+ let embeddedDaemon: any = null;
123
+
124
+ async function ensureDaemon(): Promise<void> {
125
+ if (options.autoStartDaemon === false || options.context?.sessionManager) {
126
+ return;
127
+ }
128
+
129
+ // 1. Fast path: check if a singleton daemon is already running
130
+ if (await isDaemonRunning(config.host, config.port)) {
131
+ console.error(`[BrowserTrack MCP] Connected to active singleton daemon at http://${config.host}:${config.port}`);
132
+ return;
133
+ }
134
+
135
+ // 2. Concurrency lock to guarantee only ONE daemon is ever started across multiple IDE sessions
136
+ const lockFile = path.join(config.dataDir, 'daemon_boot.lock');
137
+ const hasLock = acquireBootLock(lockFile);
138
+
139
+ if (!hasLock) {
140
+ // Another session is currently booting the daemon, wait for it
141
+ for (let i = 0; i < 30; i++) {
142
+ await new Promise((r) => setTimeout(r, 100));
143
+ if (await isDaemonRunning(config.host, config.port)) {
144
+ console.error(`[BrowserTrack MCP] Connected to shared singleton daemon at http://${config.host}:${config.port}`);
145
+ return;
146
+ }
147
+ }
148
+ }
149
+
150
+ try {
151
+ // 3. Double-check before starting
152
+ if (await isDaemonRunning(config.host, config.port)) {
153
+ console.error(`[BrowserTrack MCP] Connected to shared singleton daemon at http://${config.host}:${config.port}`);
154
+ return;
155
+ }
156
+
157
+ // 4. Detached background process (singleton daemon that survives across IDE windows/sessions)
158
+ if (options.detached !== false) {
159
+ const cliPath = resolveCliPath();
160
+ if (cliPath && fs.existsSync(cliPath)) {
161
+ try {
162
+ const nodePaths = [
163
+ '/usr/local/bin',
164
+ '/opt/homebrew/bin',
165
+ process.env.HOME ? `${process.env.HOME}/.nvm/versions/node/v20.19.5/bin` : '',
166
+ process.env.HOME ? `${process.env.HOME}/.nvm/versions/node/v23.1.0/bin` : '',
167
+ ].filter(Boolean);
168
+ const extendedPath = `${process.env.PATH || ''}:${nodePaths.join(':')}`;
169
+
170
+ const child = spawn(
171
+ process.execPath,
172
+ [cliPath, 'start', '--port', String(config.port), '--host', config.host],
173
+ {
174
+ detached: true,
175
+ stdio: 'ignore',
176
+ env: { ...process.env, PATH: extendedPath, BROWSERTRACK_DAEMON_DETACHED: '1' },
177
+ }
178
+ );
179
+ child.unref();
180
+
181
+ for (let i = 0; i < 30; i++) {
182
+ await new Promise((r) => setTimeout(r, 100));
183
+ if (await isDaemonRunning(config.host, config.port)) {
184
+ console.error(
185
+ `[BrowserTrack MCP] Started singleton background daemon on http://${config.host}:${config.port}`
186
+ );
187
+ return;
188
+ }
189
+ }
190
+ } catch (spawnErr: any) {
191
+ console.error(
192
+ `[BrowserTrack MCP] Detached daemon spawn failed (${spawnErr?.message}), falling back to in-process daemon.`
193
+ );
194
+ }
195
+ }
196
+ }
197
+
198
+ // 5. In-process fallback (used when detached is false or in test runners)
199
+ embeddedDaemon = createDaemon({
200
+ host: config.host,
201
+ port: config.port,
202
+ dbPath: config.dbPath,
203
+ screenshotsDir: config.screenshotsDir,
204
+ verbose: false,
205
+ });
206
+ await embeddedDaemon.start();
207
+ console.error(`[BrowserTrack MCP] Started singleton daemon on http://${config.host}:${config.port}`);
208
+
209
+ ctx.db = embeddedDaemon.db;
210
+ ctx.sessionManager = embeddedDaemon.sessionManager;
211
+ ctx.verificationEngine = embeddedDaemon.verificationEngine;
212
+ ctx.noteVerificationEngine = embeddedDaemon.noteVerificationEngine;
213
+
214
+ const cleanup = () => {
215
+ if (embeddedDaemon) {
216
+ try {
217
+ embeddedDaemon.stop();
218
+ } catch {}
219
+ embeddedDaemon = null;
220
+ }
221
+ };
222
+
223
+ process.on('exit', cleanup);
224
+ process.on('SIGINT', cleanup);
225
+ process.on('SIGTERM', cleanup);
226
+ } catch (err: any) {
227
+ console.error(
228
+ `[BrowserTrack MCP] Note: Daemon startup skipped (${err?.message}). Running MCP in standalone database mode.`
229
+ );
230
+ } finally {
231
+ if (hasLock) {
232
+ releaseBootLock(lockFile);
233
+ }
234
+ }
235
+ }
236
+
38
237
  const server = new Server(
39
238
  {
40
239
  name: 'browsertrack-mcp',
@@ -54,7 +253,11 @@ export function createMcpServer(options: McpServerOptions = {}) {
54
253
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
55
254
  const { name, arguments: args } = request.params;
56
255
  try {
57
- const result = await handleToolCall(name, args || {}, ctx);
256
+ // 20-second safety timeout so tool execution never hangs indefinitely
257
+ const timeoutPromise = new Promise((_, reject) =>
258
+ setTimeout(() => reject(new Error(`Tool execution timed out after 20s: ${name}`)), 20000)
259
+ );
260
+ const result = await Promise.race([handleToolCall(name, args || {}, ctx), timeoutPromise]);
58
261
  return {
59
262
  content: [
60
263
  {
@@ -78,9 +281,24 @@ export function createMcpServer(options: McpServerOptions = {}) {
78
281
 
79
282
  return {
80
283
  server,
284
+ ctx,
285
+ ensureDaemon,
286
+ async stopDaemon() {
287
+ if (embeddedDaemon) {
288
+ await embeddedDaemon.stop();
289
+ embeddedDaemon = null;
290
+ }
291
+ },
81
292
  async startStdio() {
293
+ // Connect stdio transport immediately so client handshakes are answered instantly
82
294
  const transport = new StdioServerTransport();
83
295
  await server.connect(transport);
296
+
297
+ // Start daemon verification / auto-boot asynchronously in background so client handshakes never block
298
+ const daemonInitPromise = ensureDaemon().catch((err) => {
299
+ console.error(`[BrowserTrack MCP] Daemon background init note: ${err?.message || err}`);
300
+ });
301
+ ctx.daemonInitPromise = daemonInitPromise;
84
302
  },
85
303
  };
86
304
  }