browsertrack 0.2.1 → 0.2.2
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-INXDWPJW.js → chunk-4HRLW6YF.js} +161 -106
- package/dist/chunk-4HRLW6YF.js.map +1 -0
- package/dist/{chunk-3HOXPTM2.js → chunk-AYSVE6NG.js} +808 -53
- package/dist/chunk-AYSVE6NG.js.map +1 -0
- package/dist/{chunk-6VA7GBAO.js → chunk-QRZ57ME3.js} +70 -2
- package/dist/chunk-QRZ57ME3.js.map +1 -0
- package/dist/{chunk-464D4U2U.js → chunk-TWEYRBDU.js} +279 -43
- package/dist/chunk-TWEYRBDU.js.map +1 -0
- package/dist/cli/index.js +1038 -545
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +184 -104
- package/dist/client/index.js +2 -2
- package/dist/client.iife.js +9 -9
- package/dist/core/index.d.ts +24 -1
- package/dist/core/index.js +9 -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 +14 -7
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +7 -4
- package/dist/{server-DiVmTrIR.d.ts → server-DjV7RWQM.d.ts} +9 -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 +81 -48
- 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/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 +114 -45
- package/packages/mcp/src/server.ts +202 -2
- package/test/core/safety.test.ts +106 -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,
|
|
@@ -11,6 +11,113 @@ export interface McpContext {
|
|
|
11
11
|
daemonUrl?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
async function sendSessionCommand(ctx: McpContext, sessionId: string | undefined, command: any, timeoutMs = 5000): Promise<any> {
|
|
15
|
+
// 1. In-process session manager (if active sockets exist)
|
|
16
|
+
if (ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
17
|
+
const targetSession = sessionId ? ctx.db.getSession(sessionId) : ctx.sessionManager.getAnyActiveSession();
|
|
18
|
+
if (targetSession) {
|
|
19
|
+
const res = await ctx.sessionManager.sendCommand(targetSession.id, command, timeoutMs);
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(res.error || res.reason || `Command ${command.type} failed`);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Multi-process proxy to running daemon HTTP server
|
|
28
|
+
if (ctx.daemonUrl) {
|
|
29
|
+
try {
|
|
30
|
+
const resp = await fetch(`${ctx.daemonUrl}/api/command`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ sessionId, command, timeoutMs }),
|
|
34
|
+
signal: AbortSignal.timeout(timeoutMs + 2000),
|
|
35
|
+
});
|
|
36
|
+
if (resp.ok) {
|
|
37
|
+
const res = await resp.json();
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(res.error || res.reason || `Command ${command.type} failed`);
|
|
40
|
+
}
|
|
41
|
+
return res;
|
|
42
|
+
}
|
|
43
|
+
const errJson = await resp.json().catch(() => ({}));
|
|
44
|
+
if (errJson.error) {
|
|
45
|
+
throw new Error(errJson.error);
|
|
46
|
+
}
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
if (err.message && !err.message.includes('fetch failed') && !err.message.includes('ECONNREFUSED')) {
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new Error(
|
|
55
|
+
'No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runVerifyIncident(ctx: McpContext, incidentId: string, options: any): Promise<any> {
|
|
60
|
+
if (ctx.verificationEngine && ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
61
|
+
return await ctx.verificationEngine.verifyIncident(incidentId, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (ctx.daemonUrl) {
|
|
65
|
+
try {
|
|
66
|
+
const timeoutMs = (options?.observationWindowMs || 3000) + 7000;
|
|
67
|
+
const resp = await fetch(`${ctx.daemonUrl}/api/verify/incident`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ incidentId, options }),
|
|
71
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
72
|
+
});
|
|
73
|
+
if (resp.ok) {
|
|
74
|
+
const data = await resp.json();
|
|
75
|
+
if (data.ok) return data.result;
|
|
76
|
+
throw new Error(data.error);
|
|
77
|
+
}
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
if (err.message && !err.message.includes('fetch failed') && !err.message.includes('ECONNREFUSED')) {
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(
|
|
86
|
+
'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.'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runVerifyNote(ctx: McpContext, noteId: string, options: any): Promise<any> {
|
|
91
|
+
if (ctx.noteVerificationEngine && ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
92
|
+
return await ctx.noteVerificationEngine.verifyNote(noteId, options);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (ctx.daemonUrl) {
|
|
96
|
+
try {
|
|
97
|
+
const timeoutMs = (options?.observationWindowMs || 3000) + 7000;
|
|
98
|
+
const resp = await fetch(`${ctx.daemonUrl}/api/verify/note`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ noteId, options }),
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
+
});
|
|
104
|
+
if (resp.ok) {
|
|
105
|
+
const data = await resp.json();
|
|
106
|
+
if (data.ok) return data.result;
|
|
107
|
+
throw new Error(data.error);
|
|
108
|
+
}
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
if (err.message && !err.message.includes('fetch failed') && !err.message.includes('ECONNREFUSED')) {
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new Error(
|
|
117
|
+
'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.'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
14
121
|
export async function handleToolCall(name: string, args: any, ctx: McpContext): Promise<any> {
|
|
15
122
|
const { db, sessionManager, verificationEngine, noteVerificationEngine } = ctx;
|
|
16
123
|
|
|
@@ -181,43 +288,20 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
181
288
|
}
|
|
182
289
|
|
|
183
290
|
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, {
|
|
291
|
+
const cmdRes = await sendSessionCommand(ctx, args.sessionId, {
|
|
193
292
|
id: `cmd_mcp_${Date.now()}`,
|
|
194
293
|
type: 'get_page_state',
|
|
195
294
|
});
|
|
196
|
-
|
|
197
|
-
if (!cmdRes.ok) {
|
|
198
|
-
throw new Error(cmdRes.error || 'Failed to retrieve page state from browser.');
|
|
199
|
-
}
|
|
200
295
|
return cmdRes.result;
|
|
201
296
|
}
|
|
202
297
|
|
|
203
298
|
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, {
|
|
299
|
+
const cmdRes = await sendSessionCommand(ctx, args.sessionId, {
|
|
213
300
|
id: `cmd_mcp_${Date.now()}`,
|
|
214
301
|
type: 'capture_element',
|
|
215
302
|
params: { selector: args.selector },
|
|
216
303
|
});
|
|
217
304
|
|
|
218
|
-
if (!cmdRes.ok) {
|
|
219
|
-
throw new Error(cmdRes.error || cmdRes.reason || 'Failed to capture element screenshot.');
|
|
220
|
-
}
|
|
221
305
|
return {
|
|
222
306
|
ok: true,
|
|
223
307
|
format: cmdRes.result?.format || 'webp',
|
|
@@ -228,11 +312,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
228
312
|
}
|
|
229
313
|
|
|
230
314
|
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, {
|
|
315
|
+
const res = await runVerifyIncident(ctx, args.incidentId, {
|
|
236
316
|
route: args.route,
|
|
237
317
|
targetSelector: args.targetSelector,
|
|
238
318
|
expect: args.expect,
|
|
@@ -399,10 +479,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
399
479
|
}
|
|
400
480
|
|
|
401
481
|
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, {
|
|
482
|
+
const res = await runVerifyNote(ctx, args.noteId, {
|
|
406
483
|
observationWindowMs: args.observationWindowMs,
|
|
407
484
|
});
|
|
408
485
|
return res;
|
|
@@ -417,27 +494,19 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
417
494
|
}
|
|
418
495
|
|
|
419
496
|
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, {
|
|
497
|
+
const queryCmd = await sendSessionCommand(ctx, args.sessionId, {
|
|
429
498
|
id: `cmd_ctx_${Date.now()}`,
|
|
430
499
|
type: 'query_element',
|
|
431
500
|
params: { selector: args.selector },
|
|
432
501
|
});
|
|
433
502
|
|
|
434
|
-
const overflowCmd = await
|
|
503
|
+
const overflowCmd = await sendSessionCommand(ctx, args.sessionId, {
|
|
435
504
|
id: `cmd_ovf_${Date.now()}`,
|
|
436
505
|
type: 'check_overflow',
|
|
437
506
|
params: { selector: args.selector },
|
|
438
507
|
});
|
|
439
508
|
|
|
440
|
-
const styleCmd = await
|
|
509
|
+
const styleCmd = await sendSessionCommand(ctx, args.sessionId, {
|
|
441
510
|
id: `cmd_sty_${Date.now()}`,
|
|
442
511
|
type: 'get_element_style',
|
|
443
512
|
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,113 @@ 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 child = spawn(
|
|
163
|
+
process.execPath,
|
|
164
|
+
[cliPath, 'start', '--port', String(config.port), '--host', config.host],
|
|
165
|
+
{
|
|
166
|
+
detached: true,
|
|
167
|
+
stdio: 'ignore',
|
|
168
|
+
env: { ...process.env, BROWSERTRACK_DAEMON_DETACHED: '1' },
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
child.unref();
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < 30; i++) {
|
|
174
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
175
|
+
if (await isDaemonRunning(config.host, config.port)) {
|
|
176
|
+
console.error(
|
|
177
|
+
`[BrowserTrack MCP] Started singleton background daemon on http://${config.host}:${config.port}`
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (spawnErr: any) {
|
|
183
|
+
console.error(
|
|
184
|
+
`[BrowserTrack MCP] Detached daemon spawn failed (${spawnErr?.message}), falling back to in-process daemon.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 5. In-process fallback (used when detached is false or in test runners)
|
|
191
|
+
embeddedDaemon = createDaemon({
|
|
192
|
+
host: config.host,
|
|
193
|
+
port: config.port,
|
|
194
|
+
dbPath: config.dbPath,
|
|
195
|
+
screenshotsDir: config.screenshotsDir,
|
|
196
|
+
verbose: false,
|
|
197
|
+
});
|
|
198
|
+
await embeddedDaemon.start();
|
|
199
|
+
console.error(`[BrowserTrack MCP] Started singleton daemon on http://${config.host}:${config.port}`);
|
|
200
|
+
|
|
201
|
+
ctx.db = embeddedDaemon.db;
|
|
202
|
+
ctx.sessionManager = embeddedDaemon.sessionManager;
|
|
203
|
+
ctx.verificationEngine = embeddedDaemon.verificationEngine;
|
|
204
|
+
ctx.noteVerificationEngine = embeddedDaemon.noteVerificationEngine;
|
|
205
|
+
|
|
206
|
+
const cleanup = () => {
|
|
207
|
+
if (embeddedDaemon) {
|
|
208
|
+
try {
|
|
209
|
+
embeddedDaemon.stop();
|
|
210
|
+
} catch {}
|
|
211
|
+
embeddedDaemon = null;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
process.on('exit', cleanup);
|
|
216
|
+
process.on('SIGINT', cleanup);
|
|
217
|
+
process.on('SIGTERM', cleanup);
|
|
218
|
+
} catch (err: any) {
|
|
219
|
+
console.error(
|
|
220
|
+
`[BrowserTrack MCP] Note: Daemon startup skipped (${err?.message}). Running MCP in standalone database mode.`
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
if (hasLock) {
|
|
224
|
+
releaseBootLock(lockFile);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
38
229
|
const server = new Server(
|
|
39
230
|
{
|
|
40
231
|
name: 'browsertrack-mcp',
|
|
@@ -78,7 +269,16 @@ export function createMcpServer(options: McpServerOptions = {}) {
|
|
|
78
269
|
|
|
79
270
|
return {
|
|
80
271
|
server,
|
|
272
|
+
ctx,
|
|
273
|
+
ensureDaemon,
|
|
274
|
+
async stopDaemon() {
|
|
275
|
+
if (embeddedDaemon) {
|
|
276
|
+
await embeddedDaemon.stop();
|
|
277
|
+
embeddedDaemon = null;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
81
280
|
async startStdio() {
|
|
281
|
+
await ensureDaemon();
|
|
82
282
|
const transport = new StdioServerTransport();
|
|
83
283
|
await server.connect(transport);
|
|
84
284
|
},
|