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.
- package/AGENTS.md +9 -6
- package/README.md +2 -0
- package/dist/{chunk-464D4U2U.js → chunk-5NR5K3ER.js} +299 -44
- package/dist/chunk-5NR5K3ER.js.map +1 -0
- package/dist/{chunk-3HOXPTM2.js → chunk-G5CIZSQM.js} +808 -53
- package/dist/chunk-G5CIZSQM.js.map +1 -0
- package/dist/{chunk-6VA7GBAO.js → chunk-ONPW7AYL.js} +144 -12
- package/dist/chunk-ONPW7AYL.js.map +1 -0
- package/dist/{chunk-INXDWPJW.js → chunk-PG4JJDCV.js} +353 -119
- package/dist/chunk-PG4JJDCV.js.map +1 -0
- package/dist/cli/index.js +1058 -546
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +449 -128
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -2
- package/dist/client.iife.js +121 -15
- package/dist/core/index.d.ts +32 -2
- package/dist/core/index.js +11 -1
- package/dist/daemon/index.d.ts +2 -2
- package/dist/daemon/index.js +6 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +16 -7
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +7 -4
- package/dist/{server-DiVmTrIR.d.ts → server-BRG-RQQP.d.ts} +10 -1
- package/docs/cli.md +4 -1
- package/docs/getting-started.md +60 -6
- package/docs/mcp-reference.md +42 -0
- package/package.json +1 -1
- package/packages/cli/src/index.ts +247 -151
- package/packages/client/src/interceptors/navigation.ts +38 -26
- package/packages/client/src/interceptors/network.ts +22 -17
- package/packages/client/src/notes/inspector.ts +289 -65
- package/packages/client/src/source/resolver.ts +9 -3
- package/packages/client/src/transport/websocket.ts +23 -18
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/safety.ts +86 -0
- package/packages/core/src/selector.ts +110 -15
- package/packages/daemon/src/server/daemon.ts +7 -1
- package/packages/daemon/src/server/http.ts +125 -5
- package/packages/daemon/src/server/ws.ts +33 -29
- package/packages/daemon/src/storage/db.ts +57 -35
- package/packages/mcp/src/handlers.ts +121 -45
- package/packages/mcp/src/server.ts +221 -3
- package/test/core/safety.test.ts +106 -0
- package/test/core/selector.test.ts +133 -0
- package/test/daemon/storage.test.ts +36 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
- package/test/mcp/auto-start.test.ts +87 -0
- package/dist/chunk-3HOXPTM2.js.map +0 -1
- package/dist/chunk-464D4U2U.js.map +0 -1
- package/dist/chunk-6VA7GBAO.js.map +0 -1
- package/dist/chunk-7OCOQGDN.js +0 -635
- package/dist/chunk-7OCOQGDN.js.map +0 -1
- 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.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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('
|
|
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:
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
incident.lastElement ?
|
|
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
|
-
|
|
479
|
-
update.lastElement ?
|
|
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
|
-
|
|
504
|
-
occurrence.lastElement ?
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
note.target ?
|
|
573
|
-
note.elementContext ?
|
|
574
|
-
note.region ?
|
|
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
|
-
|
|
743
|
-
v.geometryDiff ?
|
|
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:
|
|
758
|
-
geometryDiff:
|
|
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:
|
|
802
|
-
networkFailures:
|
|
803
|
-
lastElement:
|
|
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:
|
|
822
|
-
scroll:
|
|
823
|
-
target:
|
|
824
|
-
elementContext:
|
|
825
|
-
region:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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/
|
|
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({
|
|
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
|
-
|
|
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
|
}
|