browser-debug-mcp-bridge 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +101 -0
- package/apps/mcp-server/dist/db/connection.js +42 -0
- package/apps/mcp-server/dist/db/connection.js.map +1 -0
- package/apps/mcp-server/dist/db/error-fingerprints.js +21 -0
- package/apps/mcp-server/dist/db/error-fingerprints.js.map +1 -0
- package/apps/mcp-server/dist/db/events-repository.js +109 -0
- package/apps/mcp-server/dist/db/events-repository.js.map +1 -0
- package/apps/mcp-server/dist/db/index.js +4 -0
- package/apps/mcp-server/dist/db/index.js.map +1 -0
- package/apps/mcp-server/dist/db/migrations.js +101 -0
- package/apps/mcp-server/dist/db/migrations.js.map +1 -0
- package/apps/mcp-server/dist/db/schema.js +157 -0
- package/apps/mcp-server/dist/db/schema.js.map +1 -0
- package/apps/mcp-server/dist/main.js +384 -0
- package/apps/mcp-server/dist/main.js.map +1 -0
- package/apps/mcp-server/dist/mcp/server.js +1619 -0
- package/apps/mcp-server/dist/mcp/server.js.map +1 -0
- package/apps/mcp-server/dist/mcp-bridge.js +55 -0
- package/apps/mcp-server/dist/mcp-bridge.js.map +1 -0
- package/apps/mcp-server/dist/retention.js +841 -0
- package/apps/mcp-server/dist/retention.js.map +1 -0
- package/apps/mcp-server/dist/websocket/index.js +3 -0
- package/apps/mcp-server/dist/websocket/index.js.map +1 -0
- package/apps/mcp-server/dist/websocket/messages.js +150 -0
- package/apps/mcp-server/dist/websocket/messages.js.map +1 -0
- package/apps/mcp-server/dist/websocket/websocket-server.js +302 -0
- package/apps/mcp-server/dist/websocket/websocket-server.js.map +1 -0
- package/apps/mcp-server/package.json +28 -0
- package/package.json +88 -0
- package/scripts/mcp-start.cjs +229 -0
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { dirname, resolve } from 'path';
|
|
6
|
+
import { getConnection } from '../db/connection';
|
|
7
|
+
function createDefaultMcpLogger() {
|
|
8
|
+
const write = (level, message, payload) => {
|
|
9
|
+
process.stderr.write(`${message} ${JSON.stringify({ level, ...payload })}\n`);
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
info: (payload, message) => {
|
|
13
|
+
write('info', message ?? '[MCPServer][MCP][info]', payload);
|
|
14
|
+
},
|
|
15
|
+
error: (payload, message) => {
|
|
16
|
+
write('error', message ?? '[MCPServer][MCP][error]', payload);
|
|
17
|
+
},
|
|
18
|
+
debug: (payload, message) => {
|
|
19
|
+
write('debug', message ?? '[MCPServer][MCP][debug]', payload);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const TOOL_SCHEMAS = {
|
|
24
|
+
list_sessions: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
sinceMinutes: { type: 'number' },
|
|
28
|
+
limit: { type: 'number' },
|
|
29
|
+
offset: { type: 'number' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
get_session_summary: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
required: ['sessionId'],
|
|
35
|
+
properties: {
|
|
36
|
+
sessionId: { type: 'string' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
get_recent_events: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
required: ['sessionId'],
|
|
42
|
+
properties: {
|
|
43
|
+
sessionId: { type: 'string' },
|
|
44
|
+
eventTypes: { type: 'array', items: { type: 'string' } },
|
|
45
|
+
limit: { type: 'number' },
|
|
46
|
+
offset: { type: 'number' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
get_navigation_history: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
required: ['sessionId'],
|
|
52
|
+
properties: {
|
|
53
|
+
sessionId: { type: 'string' },
|
|
54
|
+
limit: { type: 'number' },
|
|
55
|
+
offset: { type: 'number' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
get_console_events: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
required: ['sessionId'],
|
|
61
|
+
properties: {
|
|
62
|
+
sessionId: { type: 'string' },
|
|
63
|
+
level: { type: 'string' },
|
|
64
|
+
limit: { type: 'number' },
|
|
65
|
+
offset: { type: 'number' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
get_error_fingerprints: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
sessionId: { type: 'string' },
|
|
72
|
+
sinceMinutes: { type: 'number' },
|
|
73
|
+
limit: { type: 'number' },
|
|
74
|
+
offset: { type: 'number' },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
get_network_failures: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
sessionId: { type: 'string' },
|
|
81
|
+
errorType: { type: 'string' },
|
|
82
|
+
groupBy: { type: 'string' },
|
|
83
|
+
limit: { type: 'number' },
|
|
84
|
+
offset: { type: 'number' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
get_element_refs: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
required: ['sessionId', 'selector'],
|
|
90
|
+
properties: {
|
|
91
|
+
sessionId: { type: 'string' },
|
|
92
|
+
selector: { type: 'string' },
|
|
93
|
+
limit: { type: 'number' },
|
|
94
|
+
offset: { type: 'number' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
get_dom_subtree: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
required: ['sessionId', 'selector'],
|
|
100
|
+
properties: {
|
|
101
|
+
sessionId: { type: 'string' },
|
|
102
|
+
selector: { type: 'string' },
|
|
103
|
+
maxDepth: { type: 'number' },
|
|
104
|
+
maxBytes: { type: 'number' },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
get_dom_document: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
required: ['sessionId'],
|
|
110
|
+
properties: {
|
|
111
|
+
sessionId: { type: 'string' },
|
|
112
|
+
mode: { type: 'string' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
get_computed_styles: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
required: ['sessionId', 'selector'],
|
|
118
|
+
properties: {
|
|
119
|
+
sessionId: { type: 'string' },
|
|
120
|
+
selector: { type: 'string' },
|
|
121
|
+
properties: { type: 'array', items: { type: 'string' } },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
get_layout_metrics: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
required: ['sessionId'],
|
|
127
|
+
properties: {
|
|
128
|
+
sessionId: { type: 'string' },
|
|
129
|
+
selector: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
capture_ui_snapshot: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
required: ['sessionId'],
|
|
135
|
+
properties: {
|
|
136
|
+
sessionId: { type: 'string' },
|
|
137
|
+
selector: { type: 'string' },
|
|
138
|
+
trigger: { type: 'string' },
|
|
139
|
+
mode: { type: 'string' },
|
|
140
|
+
styleMode: { type: 'string' },
|
|
141
|
+
maxDepth: { type: 'number' },
|
|
142
|
+
maxBytes: { type: 'number' },
|
|
143
|
+
maxAncestors: { type: 'number' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
explain_last_failure: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
required: ['sessionId'],
|
|
149
|
+
properties: {
|
|
150
|
+
sessionId: { type: 'string' },
|
|
151
|
+
lookbackSeconds: { type: 'number' },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
get_event_correlation: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
required: ['sessionId', 'eventId'],
|
|
157
|
+
properties: {
|
|
158
|
+
sessionId: { type: 'string' },
|
|
159
|
+
eventId: { type: 'string' },
|
|
160
|
+
windowSeconds: { type: 'number' },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
list_snapshots: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
required: ['sessionId'],
|
|
166
|
+
properties: {
|
|
167
|
+
sessionId: { type: 'string' },
|
|
168
|
+
trigger: { type: 'string' },
|
|
169
|
+
sinceTimestamp: { type: 'number' },
|
|
170
|
+
untilTimestamp: { type: 'number' },
|
|
171
|
+
limit: { type: 'number' },
|
|
172
|
+
offset: { type: 'number' },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
get_snapshot_for_event: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
required: ['sessionId', 'eventId'],
|
|
178
|
+
properties: {
|
|
179
|
+
sessionId: { type: 'string' },
|
|
180
|
+
eventId: { type: 'string' },
|
|
181
|
+
maxDeltaMs: { type: 'number' },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
get_snapshot_asset: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
required: ['sessionId', 'snapshotId'],
|
|
187
|
+
properties: {
|
|
188
|
+
sessionId: { type: 'string' },
|
|
189
|
+
snapshotId: { type: 'string' },
|
|
190
|
+
asset: { type: 'string' },
|
|
191
|
+
offset: { type: 'number' },
|
|
192
|
+
maxBytes: { type: 'number' },
|
|
193
|
+
encoding: { type: 'string' },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
const TOOL_DESCRIPTIONS = {
|
|
198
|
+
list_sessions: 'List captured debugging sessions',
|
|
199
|
+
get_session_summary: 'Get summary counters for one session',
|
|
200
|
+
get_recent_events: 'Read recent events from a session',
|
|
201
|
+
get_navigation_history: 'Read navigation events for a session',
|
|
202
|
+
get_console_events: 'Read console events for a session',
|
|
203
|
+
get_error_fingerprints: 'List aggregated error fingerprints',
|
|
204
|
+
get_network_failures: 'List network failures and groupings',
|
|
205
|
+
get_element_refs: 'Get element references by selector',
|
|
206
|
+
get_dom_subtree: 'Capture a bounded DOM subtree',
|
|
207
|
+
get_dom_document: 'Capture full document as outline or html',
|
|
208
|
+
get_computed_styles: 'Read computed CSS styles for an element',
|
|
209
|
+
get_layout_metrics: 'Read viewport and element layout metrics',
|
|
210
|
+
capture_ui_snapshot: 'Capture redacted UI snapshot (DOM/styles/optional PNG) and persist it',
|
|
211
|
+
explain_last_failure: 'Explain the latest failure timeline',
|
|
212
|
+
get_event_correlation: 'Correlate related events by window',
|
|
213
|
+
list_snapshots: 'List snapshot metadata by session/time/trigger',
|
|
214
|
+
get_snapshot_for_event: 'Find snapshot most related to an event',
|
|
215
|
+
get_snapshot_asset: 'Read bounded binary chunks for snapshot assets',
|
|
216
|
+
};
|
|
217
|
+
const ALL_TOOLS = Object.keys(TOOL_SCHEMAS);
|
|
218
|
+
const DEFAULT_REDACTION_SUMMARY = {
|
|
219
|
+
totalFields: 0,
|
|
220
|
+
redactedFields: 0,
|
|
221
|
+
rulesApplied: [],
|
|
222
|
+
};
|
|
223
|
+
const DEFAULT_LIST_LIMIT = 25;
|
|
224
|
+
const DEFAULT_EVENT_LIMIT = 50;
|
|
225
|
+
const MAX_LIMIT = 200;
|
|
226
|
+
const DEFAULT_SNAPSHOT_ASSET_CHUNK_BYTES = 64 * 1024;
|
|
227
|
+
const MAX_SNAPSHOT_ASSET_CHUNK_BYTES = 256 * 1024;
|
|
228
|
+
const NETWORK_DOMAIN_GROUP_SQL = `
|
|
229
|
+
CASE
|
|
230
|
+
WHEN instr(replace(replace(url, 'https://', ''), 'http://', ''), '/') > 0
|
|
231
|
+
THEN substr(
|
|
232
|
+
replace(replace(url, 'https://', ''), 'http://', ''),
|
|
233
|
+
1,
|
|
234
|
+
instr(replace(replace(url, 'https://', ''), 'http://', ''), '/') - 1
|
|
235
|
+
)
|
|
236
|
+
ELSE replace(replace(url, 'https://', ''), 'http://', '')
|
|
237
|
+
END
|
|
238
|
+
`;
|
|
239
|
+
function resolveLimit(value, fallback) {
|
|
240
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
241
|
+
return fallback;
|
|
242
|
+
}
|
|
243
|
+
const floored = Math.floor(value);
|
|
244
|
+
if (floored < 1) {
|
|
245
|
+
return fallback;
|
|
246
|
+
}
|
|
247
|
+
return Math.min(floored, MAX_LIMIT);
|
|
248
|
+
}
|
|
249
|
+
function resolveOffset(value) {
|
|
250
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
const floored = Math.floor(value);
|
|
254
|
+
return floored < 0 ? 0 : floored;
|
|
255
|
+
}
|
|
256
|
+
function readJsonPayload(payloadJson) {
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(payloadJson);
|
|
259
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
260
|
+
return parsed;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// ignore malformed payloads and return an empty object
|
|
265
|
+
}
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
function mapRequestedEventType(type) {
|
|
269
|
+
switch (type) {
|
|
270
|
+
case 'navigation':
|
|
271
|
+
return 'nav';
|
|
272
|
+
case 'click':
|
|
273
|
+
case 'scroll':
|
|
274
|
+
case 'input':
|
|
275
|
+
case 'change':
|
|
276
|
+
case 'submit':
|
|
277
|
+
case 'focus':
|
|
278
|
+
case 'blur':
|
|
279
|
+
case 'keydown':
|
|
280
|
+
case 'custom':
|
|
281
|
+
return 'ui';
|
|
282
|
+
default:
|
|
283
|
+
return type;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function parseRequestedTypes(value) {
|
|
287
|
+
if (!Array.isArray(value)) {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const normalized = value
|
|
291
|
+
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
292
|
+
.map((entry) => mapRequestedEventType(entry));
|
|
293
|
+
return Array.from(new Set(normalized));
|
|
294
|
+
}
|
|
295
|
+
function resolveLastUrl(payload) {
|
|
296
|
+
const candidates = [payload.url, payload.to, payload.href, payload.location];
|
|
297
|
+
for (const candidate of candidates) {
|
|
298
|
+
if (typeof candidate === 'string' && candidate.length > 0) {
|
|
299
|
+
return candidate;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
function mapEventRecord(row) {
|
|
305
|
+
return {
|
|
306
|
+
eventId: row.event_id,
|
|
307
|
+
sessionId: row.session_id,
|
|
308
|
+
timestamp: row.ts,
|
|
309
|
+
type: row.type,
|
|
310
|
+
payload: readJsonPayload(row.payload_json),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function classifyNetworkFailure(status, errorClass) {
|
|
314
|
+
if (errorClass && errorClass.length > 0) {
|
|
315
|
+
return errorClass;
|
|
316
|
+
}
|
|
317
|
+
if (typeof status === 'number' && status >= 400) {
|
|
318
|
+
return 'http_error';
|
|
319
|
+
}
|
|
320
|
+
return 'unknown';
|
|
321
|
+
}
|
|
322
|
+
function buildNetworkFailureFilter(errorType) {
|
|
323
|
+
if (typeof errorType !== 'string' || errorType.length === 0) {
|
|
324
|
+
return '(error_class IS NOT NULL OR COALESCE(status, 0) >= 400)';
|
|
325
|
+
}
|
|
326
|
+
if (errorType === 'http_error') {
|
|
327
|
+
return "(error_class = 'http_error' OR (error_class IS NULL AND COALESCE(status, 0) >= 400))";
|
|
328
|
+
}
|
|
329
|
+
return 'error_class = ?';
|
|
330
|
+
}
|
|
331
|
+
function resolveWindowSeconds(value, fallback, maxValue) {
|
|
332
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
333
|
+
return fallback;
|
|
334
|
+
}
|
|
335
|
+
const floored = Math.floor(value);
|
|
336
|
+
if (floored < 1) {
|
|
337
|
+
return fallback;
|
|
338
|
+
}
|
|
339
|
+
return Math.min(floored, maxValue);
|
|
340
|
+
}
|
|
341
|
+
function resolveOptionalTimestamp(value) {
|
|
342
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
const floored = Math.floor(value);
|
|
346
|
+
return floored < 0 ? undefined : floored;
|
|
347
|
+
}
|
|
348
|
+
function resolveChunkBytes(value, fallback) {
|
|
349
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
350
|
+
return fallback;
|
|
351
|
+
}
|
|
352
|
+
const floored = Math.floor(value);
|
|
353
|
+
if (floored < 1) {
|
|
354
|
+
return fallback;
|
|
355
|
+
}
|
|
356
|
+
return Math.min(floored, MAX_SNAPSHOT_ASSET_CHUNK_BYTES);
|
|
357
|
+
}
|
|
358
|
+
function resolveDurationMs(value, fallback, maxValue) {
|
|
359
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
360
|
+
return fallback;
|
|
361
|
+
}
|
|
362
|
+
const floored = Math.floor(value);
|
|
363
|
+
if (floored < 1) {
|
|
364
|
+
return fallback;
|
|
365
|
+
}
|
|
366
|
+
return Math.min(floored, maxValue);
|
|
367
|
+
}
|
|
368
|
+
function normalizeAssetPath(pathValue) {
|
|
369
|
+
return pathValue.replace(/\\/gu, '/').replace(/^\/+|\/+$/gu, '');
|
|
370
|
+
}
|
|
371
|
+
function getMainDbPath(db) {
|
|
372
|
+
const entries = db.prepare('PRAGMA database_list').all();
|
|
373
|
+
const main = entries.find((entry) => entry.name === 'main');
|
|
374
|
+
if (!main || !main.file) {
|
|
375
|
+
throw new Error('Snapshot asset retrieval is unavailable for in-memory databases.');
|
|
376
|
+
}
|
|
377
|
+
return main.file;
|
|
378
|
+
}
|
|
379
|
+
function resolveSnapshotAbsolutePath(dbPath, relativeAssetPath) {
|
|
380
|
+
const baseDir = resolve(dirname(dbPath));
|
|
381
|
+
const normalized = normalizeAssetPath(relativeAssetPath);
|
|
382
|
+
const absolutePath = resolve(baseDir, normalized);
|
|
383
|
+
const inBaseDir = absolutePath === baseDir || absolutePath.startsWith(`${baseDir}\\`) || absolutePath.startsWith(`${baseDir}/`);
|
|
384
|
+
if (!inBaseDir) {
|
|
385
|
+
throw new Error('Snapshot asset path is invalid.');
|
|
386
|
+
}
|
|
387
|
+
return absolutePath;
|
|
388
|
+
}
|
|
389
|
+
function mapSnapshotMetadata(row) {
|
|
390
|
+
return {
|
|
391
|
+
snapshotId: row.snapshot_id,
|
|
392
|
+
sessionId: row.session_id,
|
|
393
|
+
triggerEventId: row.trigger_event_id ?? undefined,
|
|
394
|
+
timestamp: row.ts,
|
|
395
|
+
trigger: row.trigger,
|
|
396
|
+
selector: row.selector ?? undefined,
|
|
397
|
+
url: row.url ?? undefined,
|
|
398
|
+
mode: row.mode,
|
|
399
|
+
styleMode: row.style_mode ?? undefined,
|
|
400
|
+
hasDom: row.dom_json !== null,
|
|
401
|
+
hasStyles: row.styles_json !== null,
|
|
402
|
+
hasPng: row.png_path !== null,
|
|
403
|
+
pngBytes: row.png_bytes ?? undefined,
|
|
404
|
+
truncation: {
|
|
405
|
+
dom: row.dom_truncated === 1,
|
|
406
|
+
styles: row.styles_truncated === 1,
|
|
407
|
+
png: row.png_truncated === 1,
|
|
408
|
+
},
|
|
409
|
+
createdAt: row.created_at,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function formatUrlPath(url) {
|
|
413
|
+
try {
|
|
414
|
+
const parsed = new URL(url);
|
|
415
|
+
return `${parsed.hostname}${parsed.pathname}`;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return url;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function describeEvent(type, payload) {
|
|
422
|
+
if (type === 'nav') {
|
|
423
|
+
return `Navigation to ${resolveLastUrl(payload) ?? 'unknown URL'}`;
|
|
424
|
+
}
|
|
425
|
+
if (type === 'ui') {
|
|
426
|
+
const selector = typeof payload.selector === 'string' ? payload.selector : 'unknown target';
|
|
427
|
+
const eventType = typeof payload.eventType === 'string' ? payload.eventType : 'interaction';
|
|
428
|
+
return `User ${eventType} on ${selector}`;
|
|
429
|
+
}
|
|
430
|
+
if (type === 'console') {
|
|
431
|
+
const level = typeof payload.level === 'string' ? payload.level : 'log';
|
|
432
|
+
const message = typeof payload.message === 'string' ? payload.message : 'no message';
|
|
433
|
+
return `Console ${level}: ${message}`;
|
|
434
|
+
}
|
|
435
|
+
if (type === 'error') {
|
|
436
|
+
const message = typeof payload.message === 'string' ? payload.message : 'Unknown runtime error';
|
|
437
|
+
return `Runtime error: ${message}`;
|
|
438
|
+
}
|
|
439
|
+
return `${type} event`;
|
|
440
|
+
}
|
|
441
|
+
function describeNetworkFailure(row) {
|
|
442
|
+
const errorType = classifyNetworkFailure(row.status, row.error_class);
|
|
443
|
+
const method = row.method || 'REQUEST';
|
|
444
|
+
const target = formatUrlPath(row.url);
|
|
445
|
+
const statusText = typeof row.status === 'number' ? ` status ${row.status}` : '';
|
|
446
|
+
return `Network ${errorType}: ${method} ${target}${statusText}`;
|
|
447
|
+
}
|
|
448
|
+
function inferCorrelationRelationship(anchorType, candidateType, deltaMs) {
|
|
449
|
+
if (anchorType === 'ui' && (candidateType === 'error' || candidateType === 'network')) {
|
|
450
|
+
return deltaMs >= 0 ? 'possible_consequence' : 'possible_trigger';
|
|
451
|
+
}
|
|
452
|
+
if ((anchorType === 'error' || anchorType === 'network') && (candidateType === 'error' || candidateType === 'network')) {
|
|
453
|
+
return 'same_failure_window';
|
|
454
|
+
}
|
|
455
|
+
if (candidateType === 'nav') {
|
|
456
|
+
return 'navigation_context';
|
|
457
|
+
}
|
|
458
|
+
if (candidateType === 'ui') {
|
|
459
|
+
return deltaMs <= 0 ? 'preceding_user_action' : 'subsequent_user_action';
|
|
460
|
+
}
|
|
461
|
+
return 'temporal_proximity';
|
|
462
|
+
}
|
|
463
|
+
function scoreCorrelation(anchorType, candidateType, deltaMs, windowMs) {
|
|
464
|
+
const distance = Math.abs(deltaMs);
|
|
465
|
+
const temporalScore = Math.max(0, 1 - distance / Math.max(windowMs, 1));
|
|
466
|
+
let semanticWeight = 0.45;
|
|
467
|
+
if (anchorType === 'ui' && (candidateType === 'error' || candidateType === 'network')) {
|
|
468
|
+
semanticWeight = 0.85;
|
|
469
|
+
}
|
|
470
|
+
else if ((anchorType === 'error' || anchorType === 'network') && (candidateType === 'error' || candidateType === 'network')) {
|
|
471
|
+
semanticWeight = 0.9;
|
|
472
|
+
}
|
|
473
|
+
else if ((anchorType === 'error' || anchorType === 'network') && candidateType === 'ui') {
|
|
474
|
+
semanticWeight = 0.75;
|
|
475
|
+
}
|
|
476
|
+
else if (candidateType === 'nav') {
|
|
477
|
+
semanticWeight = 0.6;
|
|
478
|
+
}
|
|
479
|
+
const combined = semanticWeight * 0.7 + temporalScore * 0.3;
|
|
480
|
+
return Number(combined.toFixed(3));
|
|
481
|
+
}
|
|
482
|
+
function resolveCaptureBytes(value, fallback) {
|
|
483
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
484
|
+
return fallback;
|
|
485
|
+
}
|
|
486
|
+
const floored = Math.floor(value);
|
|
487
|
+
if (floored < 1_000) {
|
|
488
|
+
return fallback;
|
|
489
|
+
}
|
|
490
|
+
return Math.min(floored, 1_000_000);
|
|
491
|
+
}
|
|
492
|
+
function resolveCaptureDepth(value, fallback) {
|
|
493
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
494
|
+
return fallback;
|
|
495
|
+
}
|
|
496
|
+
const floored = Math.floor(value);
|
|
497
|
+
if (floored < 1) {
|
|
498
|
+
return fallback;
|
|
499
|
+
}
|
|
500
|
+
return Math.min(floored, 10);
|
|
501
|
+
}
|
|
502
|
+
function resolveCaptureAncestors(value, fallback) {
|
|
503
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
504
|
+
return fallback;
|
|
505
|
+
}
|
|
506
|
+
const floored = Math.floor(value);
|
|
507
|
+
if (floored < 0) {
|
|
508
|
+
return fallback;
|
|
509
|
+
}
|
|
510
|
+
return Math.min(floored, 8);
|
|
511
|
+
}
|
|
512
|
+
function asStringArray(value, maxItems) {
|
|
513
|
+
if (!Array.isArray(value)) {
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
return value
|
|
517
|
+
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
518
|
+
.slice(0, maxItems);
|
|
519
|
+
}
|
|
520
|
+
function ensureCaptureSuccess(result) {
|
|
521
|
+
if (!result.ok) {
|
|
522
|
+
throw new Error(result.error ?? 'Capture command failed');
|
|
523
|
+
}
|
|
524
|
+
return result.payload ?? {};
|
|
525
|
+
}
|
|
526
|
+
export function createV1ToolHandlers(getDb) {
|
|
527
|
+
return {
|
|
528
|
+
list_sessions: async (input) => {
|
|
529
|
+
const db = getDb();
|
|
530
|
+
const sinceMinutes = typeof input.sinceMinutes === 'number' ? input.sinceMinutes : undefined;
|
|
531
|
+
const limit = resolveLimit(input.limit, DEFAULT_LIST_LIMIT);
|
|
532
|
+
const offset = resolveOffset(input.offset);
|
|
533
|
+
const where = [];
|
|
534
|
+
const params = [];
|
|
535
|
+
if (sinceMinutes !== undefined && Number.isFinite(sinceMinutes) && sinceMinutes > 0) {
|
|
536
|
+
where.push('created_at >= ?');
|
|
537
|
+
params.push(Date.now() - Math.floor(sinceMinutes * 60_000));
|
|
538
|
+
}
|
|
539
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
540
|
+
const sql = `
|
|
541
|
+
SELECT
|
|
542
|
+
session_id,
|
|
543
|
+
created_at,
|
|
544
|
+
ended_at,
|
|
545
|
+
tab_id,
|
|
546
|
+
window_id,
|
|
547
|
+
url_start,
|
|
548
|
+
url_last,
|
|
549
|
+
user_agent,
|
|
550
|
+
viewport_w,
|
|
551
|
+
viewport_h,
|
|
552
|
+
dpr,
|
|
553
|
+
safe_mode,
|
|
554
|
+
pinned
|
|
555
|
+
FROM sessions
|
|
556
|
+
${whereClause}
|
|
557
|
+
ORDER BY created_at DESC
|
|
558
|
+
LIMIT ? OFFSET ?
|
|
559
|
+
`;
|
|
560
|
+
const rows = db.prepare(sql).all(...params, limit + 1, offset);
|
|
561
|
+
const truncated = rows.length > limit;
|
|
562
|
+
const sessions = rows.slice(0, limit).map((row) => ({
|
|
563
|
+
sessionId: row.session_id,
|
|
564
|
+
createdAt: row.created_at,
|
|
565
|
+
endedAt: row.ended_at ?? undefined,
|
|
566
|
+
tabId: row.tab_id ?? undefined,
|
|
567
|
+
windowId: row.window_id ?? undefined,
|
|
568
|
+
urlStart: row.url_start ?? undefined,
|
|
569
|
+
urlLast: row.url_last ?? undefined,
|
|
570
|
+
userAgent: row.user_agent ?? undefined,
|
|
571
|
+
viewport: row.viewport_w !== null && row.viewport_h !== null
|
|
572
|
+
? {
|
|
573
|
+
width: row.viewport_w,
|
|
574
|
+
height: row.viewport_h,
|
|
575
|
+
}
|
|
576
|
+
: undefined,
|
|
577
|
+
dpr: row.dpr ?? undefined,
|
|
578
|
+
safeMode: row.safe_mode === 1,
|
|
579
|
+
pinned: row.pinned === 1,
|
|
580
|
+
}));
|
|
581
|
+
return {
|
|
582
|
+
...createBaseResponse(),
|
|
583
|
+
limitsApplied: {
|
|
584
|
+
maxResults: limit,
|
|
585
|
+
truncated,
|
|
586
|
+
},
|
|
587
|
+
pagination: {
|
|
588
|
+
offset,
|
|
589
|
+
returned: sessions.length,
|
|
590
|
+
},
|
|
591
|
+
sessions,
|
|
592
|
+
};
|
|
593
|
+
},
|
|
594
|
+
get_session_summary: async (input) => {
|
|
595
|
+
const db = getDb();
|
|
596
|
+
const sessionId = getSessionId(input);
|
|
597
|
+
if (!sessionId) {
|
|
598
|
+
throw new Error('sessionId is required');
|
|
599
|
+
}
|
|
600
|
+
const session = db
|
|
601
|
+
.prepare('SELECT session_id, created_at, ended_at, url_last, pinned FROM sessions WHERE session_id = ?')
|
|
602
|
+
.get(sessionId);
|
|
603
|
+
if (!session) {
|
|
604
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
605
|
+
}
|
|
606
|
+
const counters = db
|
|
607
|
+
.prepare(`
|
|
608
|
+
SELECT
|
|
609
|
+
SUM(CASE WHEN type = 'error' THEN 1 ELSE 0 END) AS errors,
|
|
610
|
+
SUM(CASE WHEN type = 'console' AND json_extract(payload_json, '$.level') = 'warn' THEN 1 ELSE 0 END) AS warnings
|
|
611
|
+
FROM events
|
|
612
|
+
WHERE session_id = ?
|
|
613
|
+
`)
|
|
614
|
+
.get(sessionId);
|
|
615
|
+
const networkFails = db
|
|
616
|
+
.prepare(`
|
|
617
|
+
SELECT COUNT(*) AS count
|
|
618
|
+
FROM network
|
|
619
|
+
WHERE session_id = ?
|
|
620
|
+
AND (error_class IS NOT NULL OR COALESCE(status, 0) >= 400)
|
|
621
|
+
`)
|
|
622
|
+
.get(sessionId);
|
|
623
|
+
const latestNav = db
|
|
624
|
+
.prepare(`
|
|
625
|
+
SELECT payload_json
|
|
626
|
+
FROM events
|
|
627
|
+
WHERE session_id = ? AND type = 'nav'
|
|
628
|
+
ORDER BY ts DESC
|
|
629
|
+
LIMIT 1
|
|
630
|
+
`)
|
|
631
|
+
.get(sessionId);
|
|
632
|
+
const eventRange = db
|
|
633
|
+
.prepare(`
|
|
634
|
+
SELECT MIN(ts) AS start_ts, MAX(ts) AS end_ts
|
|
635
|
+
FROM events
|
|
636
|
+
WHERE session_id = ?
|
|
637
|
+
`)
|
|
638
|
+
.get(sessionId);
|
|
639
|
+
const navPayload = latestNav ? readJsonPayload(latestNav.payload_json) : {};
|
|
640
|
+
const lastUrl = resolveLastUrl(navPayload) ?? session.url_last ?? undefined;
|
|
641
|
+
return {
|
|
642
|
+
...createBaseResponse(sessionId),
|
|
643
|
+
counts: {
|
|
644
|
+
errors: counters.errors ?? 0,
|
|
645
|
+
warnings: counters.warnings ?? 0,
|
|
646
|
+
networkFails: networkFails.count,
|
|
647
|
+
},
|
|
648
|
+
lastUrl,
|
|
649
|
+
timeRange: {
|
|
650
|
+
start: eventRange.start_ts ?? session.created_at,
|
|
651
|
+
end: eventRange.end_ts ?? session.ended_at ?? session.created_at,
|
|
652
|
+
},
|
|
653
|
+
pinned: session.pinned === 1,
|
|
654
|
+
};
|
|
655
|
+
},
|
|
656
|
+
get_recent_events: async (input) => {
|
|
657
|
+
const db = getDb();
|
|
658
|
+
const sessionId = getSessionId(input);
|
|
659
|
+
if (!sessionId) {
|
|
660
|
+
throw new Error('sessionId is required');
|
|
661
|
+
}
|
|
662
|
+
const limit = resolveLimit(input.limit, DEFAULT_EVENT_LIMIT);
|
|
663
|
+
const offset = resolveOffset(input.offset);
|
|
664
|
+
const requestedTypes = parseRequestedTypes(input.types ?? input.eventTypes);
|
|
665
|
+
const params = [sessionId];
|
|
666
|
+
const where = ['session_id = ?'];
|
|
667
|
+
if (requestedTypes.length > 0) {
|
|
668
|
+
const placeholders = requestedTypes.map(() => '?').join(', ');
|
|
669
|
+
where.push(`type IN (${placeholders})`);
|
|
670
|
+
params.push(...requestedTypes);
|
|
671
|
+
}
|
|
672
|
+
const rows = db
|
|
673
|
+
.prepare(`
|
|
674
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
675
|
+
FROM events
|
|
676
|
+
WHERE ${where.join(' AND ')}
|
|
677
|
+
ORDER BY ts DESC
|
|
678
|
+
LIMIT ? OFFSET ?
|
|
679
|
+
`)
|
|
680
|
+
.all(...params, limit + 1, offset);
|
|
681
|
+
const truncated = rows.length > limit;
|
|
682
|
+
return {
|
|
683
|
+
...createBaseResponse(sessionId),
|
|
684
|
+
limitsApplied: {
|
|
685
|
+
maxResults: limit,
|
|
686
|
+
truncated,
|
|
687
|
+
},
|
|
688
|
+
pagination: {
|
|
689
|
+
offset,
|
|
690
|
+
returned: Math.min(rows.length, limit),
|
|
691
|
+
},
|
|
692
|
+
events: rows.slice(0, limit).map((row) => mapEventRecord(row)),
|
|
693
|
+
};
|
|
694
|
+
},
|
|
695
|
+
get_navigation_history: async (input) => {
|
|
696
|
+
const db = getDb();
|
|
697
|
+
const sessionId = getSessionId(input);
|
|
698
|
+
if (!sessionId) {
|
|
699
|
+
throw new Error('sessionId is required');
|
|
700
|
+
}
|
|
701
|
+
const limit = resolveLimit(input.limit, DEFAULT_EVENT_LIMIT);
|
|
702
|
+
const offset = resolveOffset(input.offset);
|
|
703
|
+
const rows = db
|
|
704
|
+
.prepare(`
|
|
705
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
706
|
+
FROM events
|
|
707
|
+
WHERE session_id = ? AND type = 'nav'
|
|
708
|
+
ORDER BY ts DESC
|
|
709
|
+
LIMIT ? OFFSET ?
|
|
710
|
+
`)
|
|
711
|
+
.all(sessionId, limit + 1, offset);
|
|
712
|
+
const truncated = rows.length > limit;
|
|
713
|
+
return {
|
|
714
|
+
...createBaseResponse(sessionId),
|
|
715
|
+
limitsApplied: {
|
|
716
|
+
maxResults: limit,
|
|
717
|
+
truncated,
|
|
718
|
+
},
|
|
719
|
+
pagination: {
|
|
720
|
+
offset,
|
|
721
|
+
returned: Math.min(rows.length, limit),
|
|
722
|
+
},
|
|
723
|
+
events: rows.slice(0, limit).map((row) => mapEventRecord(row)),
|
|
724
|
+
};
|
|
725
|
+
},
|
|
726
|
+
get_console_events: async (input) => {
|
|
727
|
+
const db = getDb();
|
|
728
|
+
const sessionId = getSessionId(input);
|
|
729
|
+
if (!sessionId) {
|
|
730
|
+
throw new Error('sessionId is required');
|
|
731
|
+
}
|
|
732
|
+
const level = typeof input.level === 'string' ? input.level : undefined;
|
|
733
|
+
const limit = resolveLimit(input.limit, DEFAULT_EVENT_LIMIT);
|
|
734
|
+
const offset = resolveOffset(input.offset);
|
|
735
|
+
const params = [sessionId];
|
|
736
|
+
let levelClause = '';
|
|
737
|
+
if (level) {
|
|
738
|
+
levelClause = "AND json_extract(payload_json, '$.level') = ?";
|
|
739
|
+
params.push(level);
|
|
740
|
+
}
|
|
741
|
+
const rows = db
|
|
742
|
+
.prepare(`
|
|
743
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
744
|
+
FROM events
|
|
745
|
+
WHERE session_id = ? AND type = 'console' ${levelClause}
|
|
746
|
+
ORDER BY ts DESC
|
|
747
|
+
LIMIT ? OFFSET ?
|
|
748
|
+
`)
|
|
749
|
+
.all(...params, limit + 1, offset);
|
|
750
|
+
const truncated = rows.length > limit;
|
|
751
|
+
return {
|
|
752
|
+
...createBaseResponse(sessionId),
|
|
753
|
+
limitsApplied: {
|
|
754
|
+
maxResults: limit,
|
|
755
|
+
truncated,
|
|
756
|
+
},
|
|
757
|
+
pagination: {
|
|
758
|
+
offset,
|
|
759
|
+
returned: Math.min(rows.length, limit),
|
|
760
|
+
},
|
|
761
|
+
events: rows.slice(0, limit).map((row) => mapEventRecord(row)),
|
|
762
|
+
};
|
|
763
|
+
},
|
|
764
|
+
get_error_fingerprints: async (input) => {
|
|
765
|
+
const db = getDb();
|
|
766
|
+
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
|
|
767
|
+
const sinceMinutes = typeof input.sinceMinutes === 'number' && Number.isFinite(input.sinceMinutes)
|
|
768
|
+
? Math.floor(input.sinceMinutes)
|
|
769
|
+
: undefined;
|
|
770
|
+
const limit = resolveLimit(input.limit, DEFAULT_LIST_LIMIT);
|
|
771
|
+
const offset = resolveOffset(input.offset);
|
|
772
|
+
const params = [];
|
|
773
|
+
const where = [];
|
|
774
|
+
if (sessionId) {
|
|
775
|
+
where.push('session_id = ?');
|
|
776
|
+
params.push(sessionId);
|
|
777
|
+
}
|
|
778
|
+
if (sinceMinutes !== undefined && sinceMinutes > 0) {
|
|
779
|
+
where.push('last_seen_at >= ?');
|
|
780
|
+
params.push(Date.now() - sinceMinutes * 60_000);
|
|
781
|
+
}
|
|
782
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
783
|
+
const rows = db
|
|
784
|
+
.prepare(`
|
|
785
|
+
SELECT fingerprint, session_id, count, sample_message, sample_stack, first_seen_at, last_seen_at
|
|
786
|
+
FROM error_fingerprints
|
|
787
|
+
${whereClause}
|
|
788
|
+
ORDER BY count DESC, last_seen_at DESC
|
|
789
|
+
LIMIT ? OFFSET ?
|
|
790
|
+
`)
|
|
791
|
+
.all(...params, limit + 1, offset);
|
|
792
|
+
const truncated = rows.length > limit;
|
|
793
|
+
return {
|
|
794
|
+
...createBaseResponse(sessionId),
|
|
795
|
+
limitsApplied: {
|
|
796
|
+
maxResults: limit,
|
|
797
|
+
truncated,
|
|
798
|
+
},
|
|
799
|
+
pagination: {
|
|
800
|
+
offset,
|
|
801
|
+
returned: Math.min(rows.length, limit),
|
|
802
|
+
},
|
|
803
|
+
fingerprints: rows.slice(0, limit).map((row) => ({
|
|
804
|
+
fingerprint: row.fingerprint,
|
|
805
|
+
sessionId: row.session_id,
|
|
806
|
+
count: row.count,
|
|
807
|
+
sampleMessage: row.sample_message,
|
|
808
|
+
sampleStack: row.sample_stack ?? undefined,
|
|
809
|
+
firstSeenAt: row.first_seen_at,
|
|
810
|
+
lastSeenAt: row.last_seen_at,
|
|
811
|
+
})),
|
|
812
|
+
};
|
|
813
|
+
},
|
|
814
|
+
get_network_failures: async (input) => {
|
|
815
|
+
const db = getDb();
|
|
816
|
+
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
|
|
817
|
+
const groupBy = typeof input.groupBy === 'string' ? input.groupBy : undefined;
|
|
818
|
+
const errorType = typeof input.errorType === 'string' ? input.errorType : undefined;
|
|
819
|
+
const limit = resolveLimit(input.limit, DEFAULT_LIST_LIMIT);
|
|
820
|
+
const offset = resolveOffset(input.offset);
|
|
821
|
+
const params = [];
|
|
822
|
+
const where = [];
|
|
823
|
+
const errorFilter = buildNetworkFailureFilter(errorType);
|
|
824
|
+
if (sessionId) {
|
|
825
|
+
where.push('session_id = ?');
|
|
826
|
+
params.push(sessionId);
|
|
827
|
+
}
|
|
828
|
+
where.push(errorFilter);
|
|
829
|
+
if (errorFilter === 'error_class = ?' && errorType) {
|
|
830
|
+
params.push(errorType);
|
|
831
|
+
}
|
|
832
|
+
const whereClause = `WHERE ${where.join(' AND ')}`;
|
|
833
|
+
if (groupBy === 'url' || groupBy === 'errorType' || groupBy === 'domain') {
|
|
834
|
+
const groupExpression = groupBy === 'url'
|
|
835
|
+
? 'url'
|
|
836
|
+
: groupBy === 'domain'
|
|
837
|
+
? NETWORK_DOMAIN_GROUP_SQL
|
|
838
|
+
: "COALESCE(error_class, CASE WHEN COALESCE(status, 0) >= 400 THEN 'http_error' ELSE 'unknown' END)";
|
|
839
|
+
const rows = db
|
|
840
|
+
.prepare(`
|
|
841
|
+
SELECT
|
|
842
|
+
${groupExpression} AS group_key,
|
|
843
|
+
COUNT(*) AS count,
|
|
844
|
+
MIN(ts_start) AS first_ts,
|
|
845
|
+
MAX(ts_start) AS last_ts
|
|
846
|
+
FROM network
|
|
847
|
+
${whereClause}
|
|
848
|
+
GROUP BY group_key
|
|
849
|
+
ORDER BY count DESC, last_ts DESC
|
|
850
|
+
LIMIT ? OFFSET ?
|
|
851
|
+
`)
|
|
852
|
+
.all(...params, limit + 1, offset);
|
|
853
|
+
const truncated = rows.length > limit;
|
|
854
|
+
return {
|
|
855
|
+
...createBaseResponse(sessionId),
|
|
856
|
+
limitsApplied: {
|
|
857
|
+
maxResults: limit,
|
|
858
|
+
truncated,
|
|
859
|
+
},
|
|
860
|
+
pagination: {
|
|
861
|
+
offset,
|
|
862
|
+
returned: Math.min(rows.length, limit),
|
|
863
|
+
},
|
|
864
|
+
groupBy,
|
|
865
|
+
groups: rows.slice(0, limit).map((row) => ({
|
|
866
|
+
key: row.group_key,
|
|
867
|
+
count: row.count,
|
|
868
|
+
firstSeenAt: row.first_ts,
|
|
869
|
+
lastSeenAt: row.last_ts,
|
|
870
|
+
})),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const rows = db
|
|
874
|
+
.prepare(`
|
|
875
|
+
SELECT request_id, session_id, ts_start, duration_ms, method, url, status, initiator, error_class
|
|
876
|
+
FROM network
|
|
877
|
+
${whereClause}
|
|
878
|
+
ORDER BY ts_start DESC
|
|
879
|
+
LIMIT ? OFFSET ?
|
|
880
|
+
`)
|
|
881
|
+
.all(...params, limit + 1, offset);
|
|
882
|
+
const truncated = rows.length > limit;
|
|
883
|
+
return {
|
|
884
|
+
...createBaseResponse(sessionId),
|
|
885
|
+
limitsApplied: {
|
|
886
|
+
maxResults: limit,
|
|
887
|
+
truncated,
|
|
888
|
+
},
|
|
889
|
+
pagination: {
|
|
890
|
+
offset,
|
|
891
|
+
returned: Math.min(rows.length, limit),
|
|
892
|
+
},
|
|
893
|
+
failures: rows.slice(0, limit).map((row) => ({
|
|
894
|
+
requestId: row.request_id,
|
|
895
|
+
sessionId: row.session_id,
|
|
896
|
+
timestamp: row.ts_start,
|
|
897
|
+
durationMs: row.duration_ms ?? undefined,
|
|
898
|
+
method: row.method,
|
|
899
|
+
url: row.url,
|
|
900
|
+
status: row.status ?? undefined,
|
|
901
|
+
initiator: row.initiator ?? undefined,
|
|
902
|
+
errorType: classifyNetworkFailure(row.status, row.error_class),
|
|
903
|
+
})),
|
|
904
|
+
};
|
|
905
|
+
},
|
|
906
|
+
get_element_refs: async (input) => {
|
|
907
|
+
const db = getDb();
|
|
908
|
+
const sessionId = getSessionId(input);
|
|
909
|
+
if (!sessionId) {
|
|
910
|
+
throw new Error('sessionId is required');
|
|
911
|
+
}
|
|
912
|
+
const selector = typeof input.selector === 'string' ? input.selector : undefined;
|
|
913
|
+
if (!selector) {
|
|
914
|
+
throw new Error('selector is required');
|
|
915
|
+
}
|
|
916
|
+
const limit = resolveLimit(input.limit, DEFAULT_EVENT_LIMIT);
|
|
917
|
+
const offset = resolveOffset(input.offset);
|
|
918
|
+
const rows = db
|
|
919
|
+
.prepare(`
|
|
920
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
921
|
+
FROM events
|
|
922
|
+
WHERE session_id = ?
|
|
923
|
+
AND type IN ('ui', 'element_ref')
|
|
924
|
+
AND json_extract(payload_json, '$.selector') = ?
|
|
925
|
+
ORDER BY ts DESC
|
|
926
|
+
LIMIT ? OFFSET ?
|
|
927
|
+
`)
|
|
928
|
+
.all(sessionId, selector, limit + 1, offset);
|
|
929
|
+
const truncated = rows.length > limit;
|
|
930
|
+
return {
|
|
931
|
+
...createBaseResponse(sessionId),
|
|
932
|
+
limitsApplied: {
|
|
933
|
+
maxResults: limit,
|
|
934
|
+
truncated,
|
|
935
|
+
},
|
|
936
|
+
pagination: {
|
|
937
|
+
offset,
|
|
938
|
+
returned: Math.min(rows.length, limit),
|
|
939
|
+
},
|
|
940
|
+
selector,
|
|
941
|
+
refs: rows.slice(0, limit).map((row) => mapEventRecord(row)),
|
|
942
|
+
};
|
|
943
|
+
},
|
|
944
|
+
explain_last_failure: async (input) => {
|
|
945
|
+
const db = getDb();
|
|
946
|
+
const sessionId = getSessionId(input);
|
|
947
|
+
if (!sessionId) {
|
|
948
|
+
throw new Error('sessionId is required');
|
|
949
|
+
}
|
|
950
|
+
const lookbackSeconds = resolveWindowSeconds(input.lookbackSeconds, 30, 300);
|
|
951
|
+
const windowMs = lookbackSeconds * 1000;
|
|
952
|
+
const latestErrorEvent = db
|
|
953
|
+
.prepare(`
|
|
954
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
955
|
+
FROM events
|
|
956
|
+
WHERE session_id = ?
|
|
957
|
+
AND (type = 'error' OR (type = 'console' AND json_extract(payload_json, '$.level') = 'error'))
|
|
958
|
+
ORDER BY ts DESC
|
|
959
|
+
LIMIT 1
|
|
960
|
+
`)
|
|
961
|
+
.get(sessionId);
|
|
962
|
+
const latestNetworkFailure = db
|
|
963
|
+
.prepare(`
|
|
964
|
+
SELECT request_id, session_id, ts_start, duration_ms, method, url, status, initiator, error_class
|
|
965
|
+
FROM network
|
|
966
|
+
WHERE session_id = ?
|
|
967
|
+
AND (error_class IS NOT NULL OR COALESCE(status, 0) >= 400)
|
|
968
|
+
ORDER BY ts_start DESC
|
|
969
|
+
LIMIT 1
|
|
970
|
+
`)
|
|
971
|
+
.get(sessionId);
|
|
972
|
+
const eventFailureTs = latestErrorEvent?.ts ?? -1;
|
|
973
|
+
const networkFailureTs = latestNetworkFailure?.ts_start ?? -1;
|
|
974
|
+
if (eventFailureTs < 0 && networkFailureTs < 0) {
|
|
975
|
+
return {
|
|
976
|
+
...createBaseResponse(sessionId),
|
|
977
|
+
limitsApplied: {
|
|
978
|
+
maxResults: 0,
|
|
979
|
+
truncated: false,
|
|
980
|
+
},
|
|
981
|
+
explanation: 'No failure events found for this session.',
|
|
982
|
+
timeline: [],
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
const anchorIsEvent = eventFailureTs >= networkFailureTs;
|
|
986
|
+
const anchorTs = anchorIsEvent ? eventFailureTs : networkFailureTs;
|
|
987
|
+
const anchorType = anchorIsEvent ? latestErrorEvent?.type ?? 'error' : 'network';
|
|
988
|
+
const windowStart = anchorTs - windowMs;
|
|
989
|
+
const windowEnd = anchorTs + 1_000;
|
|
990
|
+
const eventRows = db
|
|
991
|
+
.prepare(`
|
|
992
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
993
|
+
FROM events
|
|
994
|
+
WHERE session_id = ?
|
|
995
|
+
AND ts BETWEEN ? AND ?
|
|
996
|
+
ORDER BY ts ASC
|
|
997
|
+
`)
|
|
998
|
+
.all(sessionId, windowStart, windowEnd);
|
|
999
|
+
const networkRows = db
|
|
1000
|
+
.prepare(`
|
|
1001
|
+
SELECT request_id, session_id, ts_start, duration_ms, method, url, status, initiator, error_class
|
|
1002
|
+
FROM network
|
|
1003
|
+
WHERE session_id = ?
|
|
1004
|
+
AND ts_start BETWEEN ? AND ?
|
|
1005
|
+
AND (error_class IS NOT NULL OR COALESCE(status, 0) >= 400)
|
|
1006
|
+
ORDER BY ts_start ASC
|
|
1007
|
+
`)
|
|
1008
|
+
.all(sessionId, windowStart, windowEnd);
|
|
1009
|
+
const timeline = [
|
|
1010
|
+
...eventRows.map((row) => {
|
|
1011
|
+
const payload = readJsonPayload(row.payload_json);
|
|
1012
|
+
return {
|
|
1013
|
+
timestamp: row.ts,
|
|
1014
|
+
type: row.type,
|
|
1015
|
+
eventId: row.event_id,
|
|
1016
|
+
description: describeEvent(row.type, payload),
|
|
1017
|
+
payload,
|
|
1018
|
+
};
|
|
1019
|
+
}),
|
|
1020
|
+
...networkRows.map((row) => ({
|
|
1021
|
+
timestamp: row.ts_start,
|
|
1022
|
+
type: 'network',
|
|
1023
|
+
eventId: row.request_id,
|
|
1024
|
+
description: describeNetworkFailure(row),
|
|
1025
|
+
payload: {
|
|
1026
|
+
method: row.method,
|
|
1027
|
+
url: row.url,
|
|
1028
|
+
status: row.status ?? undefined,
|
|
1029
|
+
errorType: classifyNetworkFailure(row.status, row.error_class),
|
|
1030
|
+
},
|
|
1031
|
+
})),
|
|
1032
|
+
]
|
|
1033
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
1034
|
+
.slice(0, 60);
|
|
1035
|
+
const closestAction = timeline
|
|
1036
|
+
.filter((entry) => entry.type === 'ui' && entry.timestamp <= anchorTs)
|
|
1037
|
+
.at(-1);
|
|
1038
|
+
const closestNetworkFailure = timeline
|
|
1039
|
+
.filter((entry) => entry.type === 'network' && entry.timestamp <= anchorTs)
|
|
1040
|
+
.at(-1);
|
|
1041
|
+
let rootCause = '';
|
|
1042
|
+
if (anchorType === 'network' && latestNetworkFailure) {
|
|
1043
|
+
rootCause = describeNetworkFailure(latestNetworkFailure);
|
|
1044
|
+
}
|
|
1045
|
+
else if (anchorType === 'error' || anchorType === 'console') {
|
|
1046
|
+
if (closestNetworkFailure && anchorTs - closestNetworkFailure.timestamp <= 5_000) {
|
|
1047
|
+
rootCause = `Runtime failure likely connected to recent ${closestNetworkFailure.description.toLowerCase()}.`;
|
|
1048
|
+
}
|
|
1049
|
+
else if (closestAction && anchorTs - closestAction.timestamp <= 10_000) {
|
|
1050
|
+
rootCause = `Runtime failure likely triggered after user action (${closestAction.description.toLowerCase()}).`;
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
rootCause = 'Runtime failure occurred without a clear nearby trigger in the correlation window.';
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const explanation = `Latest failure at ${anchorTs} with a ${lookbackSeconds}s correlation window.`;
|
|
1057
|
+
return {
|
|
1058
|
+
...createBaseResponse(sessionId),
|
|
1059
|
+
limitsApplied: {
|
|
1060
|
+
maxResults: timeline.length,
|
|
1061
|
+
truncated: timeline.length >= 60,
|
|
1062
|
+
},
|
|
1063
|
+
explanation,
|
|
1064
|
+
rootCause,
|
|
1065
|
+
anchor: {
|
|
1066
|
+
type: anchorType,
|
|
1067
|
+
timestamp: anchorTs,
|
|
1068
|
+
},
|
|
1069
|
+
timeline,
|
|
1070
|
+
};
|
|
1071
|
+
},
|
|
1072
|
+
get_event_correlation: async (input) => {
|
|
1073
|
+
const db = getDb();
|
|
1074
|
+
const sessionId = getSessionId(input);
|
|
1075
|
+
if (!sessionId) {
|
|
1076
|
+
throw new Error('sessionId is required');
|
|
1077
|
+
}
|
|
1078
|
+
const eventId = typeof input.eventId === 'string' ? input.eventId : '';
|
|
1079
|
+
if (!eventId) {
|
|
1080
|
+
throw new Error('eventId is required');
|
|
1081
|
+
}
|
|
1082
|
+
const anchorEvent = db
|
|
1083
|
+
.prepare(`
|
|
1084
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
1085
|
+
FROM events
|
|
1086
|
+
WHERE session_id = ? AND event_id = ?
|
|
1087
|
+
LIMIT 1
|
|
1088
|
+
`)
|
|
1089
|
+
.get(sessionId, eventId);
|
|
1090
|
+
if (!anchorEvent) {
|
|
1091
|
+
throw new Error(`Event not found: ${eventId}`);
|
|
1092
|
+
}
|
|
1093
|
+
const windowSeconds = resolveWindowSeconds(input.windowSeconds, 5, 60);
|
|
1094
|
+
const windowMs = windowSeconds * 1000;
|
|
1095
|
+
const windowStart = anchorEvent.ts - windowMs;
|
|
1096
|
+
const windowEnd = anchorEvent.ts + windowMs;
|
|
1097
|
+
const nearbyEvents = db
|
|
1098
|
+
.prepare(`
|
|
1099
|
+
SELECT event_id, session_id, ts, type, payload_json
|
|
1100
|
+
FROM events
|
|
1101
|
+
WHERE session_id = ?
|
|
1102
|
+
AND event_id != ?
|
|
1103
|
+
AND ts BETWEEN ? AND ?
|
|
1104
|
+
`)
|
|
1105
|
+
.all(sessionId, eventId, windowStart, windowEnd);
|
|
1106
|
+
const nearbyNetworkFailures = db
|
|
1107
|
+
.prepare(`
|
|
1108
|
+
SELECT request_id, session_id, ts_start, duration_ms, method, url, status, initiator, error_class
|
|
1109
|
+
FROM network
|
|
1110
|
+
WHERE session_id = ?
|
|
1111
|
+
AND ts_start BETWEEN ? AND ?
|
|
1112
|
+
AND (error_class IS NOT NULL OR COALESCE(status, 0) >= 400)
|
|
1113
|
+
`)
|
|
1114
|
+
.all(sessionId, windowStart, windowEnd);
|
|
1115
|
+
const correlations = [
|
|
1116
|
+
...nearbyEvents.map((row) => {
|
|
1117
|
+
const deltaMs = row.ts - anchorEvent.ts;
|
|
1118
|
+
return {
|
|
1119
|
+
eventId: row.event_id,
|
|
1120
|
+
type: row.type,
|
|
1121
|
+
timestamp: row.ts,
|
|
1122
|
+
payload: readJsonPayload(row.payload_json),
|
|
1123
|
+
correlationScore: scoreCorrelation(anchorEvent.type, row.type, deltaMs, windowMs),
|
|
1124
|
+
relationship: inferCorrelationRelationship(anchorEvent.type, row.type, deltaMs),
|
|
1125
|
+
deltaMs,
|
|
1126
|
+
};
|
|
1127
|
+
}),
|
|
1128
|
+
...nearbyNetworkFailures.map((row) => {
|
|
1129
|
+
const deltaMs = row.ts_start - anchorEvent.ts;
|
|
1130
|
+
return {
|
|
1131
|
+
eventId: row.request_id,
|
|
1132
|
+
type: 'network',
|
|
1133
|
+
timestamp: row.ts_start,
|
|
1134
|
+
payload: {
|
|
1135
|
+
method: row.method,
|
|
1136
|
+
url: row.url,
|
|
1137
|
+
status: row.status ?? undefined,
|
|
1138
|
+
errorType: classifyNetworkFailure(row.status, row.error_class),
|
|
1139
|
+
},
|
|
1140
|
+
correlationScore: scoreCorrelation(anchorEvent.type, 'network', deltaMs, windowMs),
|
|
1141
|
+
relationship: inferCorrelationRelationship(anchorEvent.type, 'network', deltaMs),
|
|
1142
|
+
deltaMs,
|
|
1143
|
+
};
|
|
1144
|
+
}),
|
|
1145
|
+
]
|
|
1146
|
+
.sort((a, b) => {
|
|
1147
|
+
if (b.correlationScore !== a.correlationScore) {
|
|
1148
|
+
return b.correlationScore - a.correlationScore;
|
|
1149
|
+
}
|
|
1150
|
+
return Math.abs(a.deltaMs) - Math.abs(b.deltaMs);
|
|
1151
|
+
})
|
|
1152
|
+
.slice(0, 50);
|
|
1153
|
+
return {
|
|
1154
|
+
...createBaseResponse(sessionId),
|
|
1155
|
+
limitsApplied: {
|
|
1156
|
+
maxResults: 50,
|
|
1157
|
+
truncated: nearbyEvents.length + nearbyNetworkFailures.length > 50,
|
|
1158
|
+
},
|
|
1159
|
+
anchorEvent: {
|
|
1160
|
+
eventId: anchorEvent.event_id,
|
|
1161
|
+
type: anchorEvent.type,
|
|
1162
|
+
timestamp: anchorEvent.ts,
|
|
1163
|
+
payload: readJsonPayload(anchorEvent.payload_json),
|
|
1164
|
+
},
|
|
1165
|
+
windowSeconds,
|
|
1166
|
+
correlatedEvents: correlations,
|
|
1167
|
+
};
|
|
1168
|
+
},
|
|
1169
|
+
list_snapshots: async (input) => {
|
|
1170
|
+
const db = getDb();
|
|
1171
|
+
const sessionId = getSessionId(input);
|
|
1172
|
+
if (!sessionId) {
|
|
1173
|
+
throw new Error('sessionId is required');
|
|
1174
|
+
}
|
|
1175
|
+
const trigger = typeof input.trigger === 'string' && input.trigger.length > 0 ? input.trigger : undefined;
|
|
1176
|
+
const sinceTimestamp = resolveOptionalTimestamp(input.sinceTimestamp);
|
|
1177
|
+
const untilTimestamp = resolveOptionalTimestamp(input.untilTimestamp);
|
|
1178
|
+
const limit = resolveLimit(input.limit, DEFAULT_LIST_LIMIT);
|
|
1179
|
+
const offset = resolveOffset(input.offset);
|
|
1180
|
+
const where = ['session_id = ?'];
|
|
1181
|
+
const params = [sessionId];
|
|
1182
|
+
if (trigger) {
|
|
1183
|
+
where.push('trigger = ?');
|
|
1184
|
+
params.push(trigger);
|
|
1185
|
+
}
|
|
1186
|
+
if (sinceTimestamp !== undefined) {
|
|
1187
|
+
where.push('ts >= ?');
|
|
1188
|
+
params.push(sinceTimestamp);
|
|
1189
|
+
}
|
|
1190
|
+
if (untilTimestamp !== undefined) {
|
|
1191
|
+
where.push('ts <= ?');
|
|
1192
|
+
params.push(untilTimestamp);
|
|
1193
|
+
}
|
|
1194
|
+
const rows = db
|
|
1195
|
+
.prepare(`SELECT
|
|
1196
|
+
snapshot_id, session_id, trigger_event_id, ts, trigger, selector, url, mode, style_mode,
|
|
1197
|
+
dom_json, styles_json, png_path, png_mime, png_bytes,
|
|
1198
|
+
dom_truncated, styles_truncated, png_truncated, created_at
|
|
1199
|
+
FROM snapshots
|
|
1200
|
+
WHERE ${where.join(' AND ')}
|
|
1201
|
+
ORDER BY ts DESC
|
|
1202
|
+
LIMIT ? OFFSET ?`)
|
|
1203
|
+
.all(...params, limit + 1, offset);
|
|
1204
|
+
const truncated = rows.length > limit;
|
|
1205
|
+
return {
|
|
1206
|
+
...createBaseResponse(sessionId),
|
|
1207
|
+
limitsApplied: {
|
|
1208
|
+
maxResults: limit,
|
|
1209
|
+
truncated,
|
|
1210
|
+
},
|
|
1211
|
+
pagination: {
|
|
1212
|
+
offset,
|
|
1213
|
+
returned: Math.min(rows.length, limit),
|
|
1214
|
+
},
|
|
1215
|
+
snapshots: rows.slice(0, limit).map((row) => mapSnapshotMetadata(row)),
|
|
1216
|
+
};
|
|
1217
|
+
},
|
|
1218
|
+
get_snapshot_for_event: async (input) => {
|
|
1219
|
+
const db = getDb();
|
|
1220
|
+
const sessionId = getSessionId(input);
|
|
1221
|
+
if (!sessionId) {
|
|
1222
|
+
throw new Error('sessionId is required');
|
|
1223
|
+
}
|
|
1224
|
+
const eventId = typeof input.eventId === 'string' ? input.eventId : '';
|
|
1225
|
+
if (!eventId) {
|
|
1226
|
+
throw new Error('eventId is required');
|
|
1227
|
+
}
|
|
1228
|
+
const maxDeltaMs = resolveDurationMs(input.maxDeltaMs, 10_000, 60_000);
|
|
1229
|
+
const event = db
|
|
1230
|
+
.prepare('SELECT event_id, ts, type FROM events WHERE session_id = ? AND event_id = ? LIMIT 1')
|
|
1231
|
+
.get(sessionId, eventId);
|
|
1232
|
+
if (!event) {
|
|
1233
|
+
throw new Error(`Event not found: ${eventId}`);
|
|
1234
|
+
}
|
|
1235
|
+
const byTriggerLink = db
|
|
1236
|
+
.prepare(`SELECT
|
|
1237
|
+
snapshot_id, session_id, trigger_event_id, ts, trigger, selector, url, mode, style_mode,
|
|
1238
|
+
dom_json, styles_json, png_path, png_mime, png_bytes,
|
|
1239
|
+
dom_truncated, styles_truncated, png_truncated, created_at
|
|
1240
|
+
FROM snapshots
|
|
1241
|
+
WHERE session_id = ? AND trigger_event_id = ?
|
|
1242
|
+
ORDER BY ts ASC
|
|
1243
|
+
LIMIT 1`)
|
|
1244
|
+
.get(sessionId, eventId);
|
|
1245
|
+
if (byTriggerLink) {
|
|
1246
|
+
return {
|
|
1247
|
+
...createBaseResponse(sessionId),
|
|
1248
|
+
limitsApplied: {
|
|
1249
|
+
maxResults: 1,
|
|
1250
|
+
truncated: false,
|
|
1251
|
+
},
|
|
1252
|
+
event: {
|
|
1253
|
+
eventId: event.event_id,
|
|
1254
|
+
timestamp: event.ts,
|
|
1255
|
+
type: event.type,
|
|
1256
|
+
},
|
|
1257
|
+
matchReason: 'trigger_event_id',
|
|
1258
|
+
snapshot: mapSnapshotMetadata(byTriggerLink),
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const byTimestamp = db
|
|
1262
|
+
.prepare(`SELECT
|
|
1263
|
+
snapshot_id, session_id, trigger_event_id, ts, trigger, selector, url, mode, style_mode,
|
|
1264
|
+
dom_json, styles_json, png_path, png_mime, png_bytes,
|
|
1265
|
+
dom_truncated, styles_truncated, png_truncated, created_at
|
|
1266
|
+
FROM snapshots
|
|
1267
|
+
WHERE session_id = ? AND ts BETWEEN ? AND ?
|
|
1268
|
+
ORDER BY ABS(ts - ?) ASC, ts ASC
|
|
1269
|
+
LIMIT 1`)
|
|
1270
|
+
.get(sessionId, event.ts, event.ts + maxDeltaMs, event.ts);
|
|
1271
|
+
return {
|
|
1272
|
+
...createBaseResponse(sessionId),
|
|
1273
|
+
limitsApplied: {
|
|
1274
|
+
maxResults: 1,
|
|
1275
|
+
truncated: false,
|
|
1276
|
+
},
|
|
1277
|
+
event: {
|
|
1278
|
+
eventId: event.event_id,
|
|
1279
|
+
timestamp: event.ts,
|
|
1280
|
+
type: event.type,
|
|
1281
|
+
},
|
|
1282
|
+
matchReason: byTimestamp ? 'nearest_timestamp' : 'none',
|
|
1283
|
+
snapshot: byTimestamp ? mapSnapshotMetadata(byTimestamp) : null,
|
|
1284
|
+
};
|
|
1285
|
+
},
|
|
1286
|
+
get_snapshot_asset: async (input) => {
|
|
1287
|
+
const db = getDb();
|
|
1288
|
+
const sessionId = getSessionId(input);
|
|
1289
|
+
if (!sessionId) {
|
|
1290
|
+
throw new Error('sessionId is required');
|
|
1291
|
+
}
|
|
1292
|
+
const snapshotId = typeof input.snapshotId === 'string' ? input.snapshotId : '';
|
|
1293
|
+
if (!snapshotId) {
|
|
1294
|
+
throw new Error('snapshotId is required');
|
|
1295
|
+
}
|
|
1296
|
+
const assetType = input.asset === 'png' ? 'png' : 'png';
|
|
1297
|
+
const encoding = input.encoding === 'base64' ? 'base64' : 'raw';
|
|
1298
|
+
const offset = resolveOffset(input.offset);
|
|
1299
|
+
const maxBytes = resolveChunkBytes(input.maxBytes, DEFAULT_SNAPSHOT_ASSET_CHUNK_BYTES);
|
|
1300
|
+
const snapshot = db
|
|
1301
|
+
.prepare(`SELECT snapshot_id, session_id, png_path, png_mime, png_bytes
|
|
1302
|
+
FROM snapshots
|
|
1303
|
+
WHERE session_id = ? AND snapshot_id = ?
|
|
1304
|
+
LIMIT 1`)
|
|
1305
|
+
.get(sessionId, snapshotId);
|
|
1306
|
+
if (!snapshot) {
|
|
1307
|
+
throw new Error(`Snapshot not found: ${snapshotId}`);
|
|
1308
|
+
}
|
|
1309
|
+
if (assetType !== 'png' || !snapshot.png_path) {
|
|
1310
|
+
throw new Error('Requested snapshot asset is not available.');
|
|
1311
|
+
}
|
|
1312
|
+
const dbPath = getMainDbPath(db);
|
|
1313
|
+
const absolutePath = resolveSnapshotAbsolutePath(dbPath, snapshot.png_path);
|
|
1314
|
+
if (!existsSync(absolutePath)) {
|
|
1315
|
+
throw new Error(`Snapshot asset is missing on disk: ${snapshot.png_path}`);
|
|
1316
|
+
}
|
|
1317
|
+
const fullBuffer = readFileSync(absolutePath);
|
|
1318
|
+
if (offset >= fullBuffer.byteLength) {
|
|
1319
|
+
return {
|
|
1320
|
+
...createBaseResponse(sessionId),
|
|
1321
|
+
limitsApplied: {
|
|
1322
|
+
maxResults: maxBytes,
|
|
1323
|
+
truncated: false,
|
|
1324
|
+
},
|
|
1325
|
+
snapshotId,
|
|
1326
|
+
asset: assetType,
|
|
1327
|
+
mime: snapshot.png_mime ?? 'image/png',
|
|
1328
|
+
totalBytes: fullBuffer.byteLength,
|
|
1329
|
+
offset,
|
|
1330
|
+
returnedBytes: 0,
|
|
1331
|
+
hasMore: false,
|
|
1332
|
+
nextOffset: null,
|
|
1333
|
+
encoding,
|
|
1334
|
+
chunk: encoding === 'raw' ? [] : undefined,
|
|
1335
|
+
chunkBase64: encoding === 'base64' ? '' : undefined,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
const chunkBuffer = fullBuffer.subarray(offset, Math.min(offset + maxBytes, fullBuffer.byteLength));
|
|
1339
|
+
const returnedBytes = chunkBuffer.byteLength;
|
|
1340
|
+
const nextOffset = offset + returnedBytes;
|
|
1341
|
+
const hasMore = nextOffset < fullBuffer.byteLength;
|
|
1342
|
+
return {
|
|
1343
|
+
...createBaseResponse(sessionId),
|
|
1344
|
+
limitsApplied: {
|
|
1345
|
+
maxResults: maxBytes,
|
|
1346
|
+
truncated: hasMore,
|
|
1347
|
+
},
|
|
1348
|
+
snapshotId,
|
|
1349
|
+
asset: assetType,
|
|
1350
|
+
mime: snapshot.png_mime ?? 'image/png',
|
|
1351
|
+
totalBytes: fullBuffer.byteLength,
|
|
1352
|
+
offset,
|
|
1353
|
+
returnedBytes,
|
|
1354
|
+
hasMore,
|
|
1355
|
+
nextOffset: hasMore ? nextOffset : null,
|
|
1356
|
+
encoding,
|
|
1357
|
+
chunk: encoding === 'raw' ? Array.from(chunkBuffer.values()) : undefined,
|
|
1358
|
+
chunkBase64: encoding === 'base64' ? chunkBuffer.toString('base64') : undefined,
|
|
1359
|
+
};
|
|
1360
|
+
},
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
export function createV2ToolHandlers(captureClient) {
|
|
1364
|
+
return {
|
|
1365
|
+
get_dom_subtree: async (input) => {
|
|
1366
|
+
const sessionId = getSessionId(input);
|
|
1367
|
+
if (!sessionId) {
|
|
1368
|
+
throw new Error('sessionId is required');
|
|
1369
|
+
}
|
|
1370
|
+
const selector = typeof input.selector === 'string' ? input.selector : '';
|
|
1371
|
+
if (!selector) {
|
|
1372
|
+
throw new Error('selector is required');
|
|
1373
|
+
}
|
|
1374
|
+
const maxDepth = resolveCaptureDepth(input.maxDepth, 3);
|
|
1375
|
+
const maxBytes = resolveCaptureBytes(input.maxBytes, 50_000);
|
|
1376
|
+
const capture = await captureClient.execute(sessionId, 'CAPTURE_DOM_SUBTREE', { selector, maxDepth, maxBytes }, 4_000);
|
|
1377
|
+
return {
|
|
1378
|
+
...createBaseResponse(sessionId),
|
|
1379
|
+
limitsApplied: {
|
|
1380
|
+
maxResults: maxBytes,
|
|
1381
|
+
truncated: capture.truncated ?? false,
|
|
1382
|
+
},
|
|
1383
|
+
...ensureCaptureSuccess(capture),
|
|
1384
|
+
};
|
|
1385
|
+
},
|
|
1386
|
+
get_dom_document: async (input) => {
|
|
1387
|
+
const sessionId = getSessionId(input);
|
|
1388
|
+
if (!sessionId) {
|
|
1389
|
+
throw new Error('sessionId is required');
|
|
1390
|
+
}
|
|
1391
|
+
const mode = input.mode === 'html' ? 'html' : 'outline';
|
|
1392
|
+
const maxBytes = resolveCaptureBytes(input.maxBytes, 200_000);
|
|
1393
|
+
const maxDepth = resolveCaptureDepth(input.maxDepth, 4);
|
|
1394
|
+
try {
|
|
1395
|
+
const capture = await captureClient.execute(sessionId, 'CAPTURE_DOM_DOCUMENT', { mode, maxBytes, maxDepth }, 4_000);
|
|
1396
|
+
return {
|
|
1397
|
+
...createBaseResponse(sessionId),
|
|
1398
|
+
limitsApplied: {
|
|
1399
|
+
maxResults: maxBytes,
|
|
1400
|
+
truncated: capture.truncated ?? false,
|
|
1401
|
+
},
|
|
1402
|
+
...ensureCaptureSuccess(capture),
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
if (mode !== 'html') {
|
|
1407
|
+
throw error;
|
|
1408
|
+
}
|
|
1409
|
+
const fallback = await captureClient.execute(sessionId, 'CAPTURE_DOM_DOCUMENT', { mode: 'outline', maxBytes, maxDepth }, 4_000);
|
|
1410
|
+
return {
|
|
1411
|
+
...createBaseResponse(sessionId),
|
|
1412
|
+
limitsApplied: {
|
|
1413
|
+
maxResults: maxBytes,
|
|
1414
|
+
truncated: true,
|
|
1415
|
+
},
|
|
1416
|
+
fallbackReason: 'timeout',
|
|
1417
|
+
...ensureCaptureSuccess(fallback),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
get_computed_styles: async (input) => {
|
|
1422
|
+
const sessionId = getSessionId(input);
|
|
1423
|
+
if (!sessionId) {
|
|
1424
|
+
throw new Error('sessionId is required');
|
|
1425
|
+
}
|
|
1426
|
+
const selector = typeof input.selector === 'string' ? input.selector : '';
|
|
1427
|
+
if (!selector) {
|
|
1428
|
+
throw new Error('selector is required');
|
|
1429
|
+
}
|
|
1430
|
+
const properties = asStringArray(input.properties, 64);
|
|
1431
|
+
const capture = await captureClient.execute(sessionId, 'CAPTURE_COMPUTED_STYLES', { selector, properties }, 3_000);
|
|
1432
|
+
return {
|
|
1433
|
+
...createBaseResponse(sessionId),
|
|
1434
|
+
limitsApplied: {
|
|
1435
|
+
maxResults: properties.length || 8,
|
|
1436
|
+
truncated: capture.truncated ?? false,
|
|
1437
|
+
},
|
|
1438
|
+
...ensureCaptureSuccess(capture),
|
|
1439
|
+
};
|
|
1440
|
+
},
|
|
1441
|
+
get_layout_metrics: async (input) => {
|
|
1442
|
+
const sessionId = getSessionId(input);
|
|
1443
|
+
if (!sessionId) {
|
|
1444
|
+
throw new Error('sessionId is required');
|
|
1445
|
+
}
|
|
1446
|
+
const selector = typeof input.selector === 'string' ? input.selector : undefined;
|
|
1447
|
+
const capture = await captureClient.execute(sessionId, 'CAPTURE_LAYOUT_METRICS', { selector }, 3_000);
|
|
1448
|
+
return {
|
|
1449
|
+
...createBaseResponse(sessionId),
|
|
1450
|
+
limitsApplied: {
|
|
1451
|
+
maxResults: 1,
|
|
1452
|
+
truncated: capture.truncated ?? false,
|
|
1453
|
+
},
|
|
1454
|
+
...ensureCaptureSuccess(capture),
|
|
1455
|
+
};
|
|
1456
|
+
},
|
|
1457
|
+
capture_ui_snapshot: async (input) => {
|
|
1458
|
+
const sessionId = getSessionId(input);
|
|
1459
|
+
if (!sessionId) {
|
|
1460
|
+
throw new Error('sessionId is required');
|
|
1461
|
+
}
|
|
1462
|
+
const trigger = input.trigger === 'click' || input.trigger === 'manual' || input.trigger === 'navigation' || input.trigger === 'error'
|
|
1463
|
+
? input.trigger
|
|
1464
|
+
: 'manual';
|
|
1465
|
+
const mode = input.mode === 'dom' || input.mode === 'png' || input.mode === 'both' ? input.mode : 'dom';
|
|
1466
|
+
const styleMode = input.styleMode === 'computed-full' || input.styleMode === 'computed-lite'
|
|
1467
|
+
? input.styleMode
|
|
1468
|
+
: 'computed-lite';
|
|
1469
|
+
const explicitStyleMode = input.styleMode === 'computed-full' || input.styleMode === 'computed-lite';
|
|
1470
|
+
const selector = typeof input.selector === 'string' && input.selector.trim().length > 0
|
|
1471
|
+
? input.selector.trim()
|
|
1472
|
+
: undefined;
|
|
1473
|
+
const maxDepth = resolveCaptureDepth(input.maxDepth, 3);
|
|
1474
|
+
const maxBytes = resolveCaptureBytes(input.maxBytes, 50_000);
|
|
1475
|
+
const maxAncestors = resolveCaptureAncestors(input.maxAncestors, 4);
|
|
1476
|
+
const capture = await captureClient.execute(sessionId, 'CAPTURE_UI_SNAPSHOT', {
|
|
1477
|
+
selector,
|
|
1478
|
+
trigger,
|
|
1479
|
+
mode,
|
|
1480
|
+
styleMode,
|
|
1481
|
+
explicitStyleMode,
|
|
1482
|
+
maxDepth,
|
|
1483
|
+
maxBytes,
|
|
1484
|
+
maxAncestors,
|
|
1485
|
+
llmRequested: true,
|
|
1486
|
+
}, 5_000);
|
|
1487
|
+
return {
|
|
1488
|
+
...createBaseResponse(sessionId),
|
|
1489
|
+
limitsApplied: {
|
|
1490
|
+
maxResults: maxBytes,
|
|
1491
|
+
truncated: capture.truncated ?? false,
|
|
1492
|
+
},
|
|
1493
|
+
...ensureCaptureSuccess(capture),
|
|
1494
|
+
};
|
|
1495
|
+
},
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
function isRecord(value) {
|
|
1499
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1500
|
+
}
|
|
1501
|
+
function getSessionId(input) {
|
|
1502
|
+
return typeof input.sessionId === 'string' ? input.sessionId : undefined;
|
|
1503
|
+
}
|
|
1504
|
+
function createBaseResponse(sessionId) {
|
|
1505
|
+
return {
|
|
1506
|
+
sessionId,
|
|
1507
|
+
limitsApplied: {
|
|
1508
|
+
maxResults: 0,
|
|
1509
|
+
truncated: false,
|
|
1510
|
+
},
|
|
1511
|
+
redactionSummary: DEFAULT_REDACTION_SUMMARY,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
function createDefaultHandler(toolName) {
|
|
1515
|
+
return async (input) => {
|
|
1516
|
+
return {
|
|
1517
|
+
...createBaseResponse(getSessionId(input)),
|
|
1518
|
+
tool: toolName,
|
|
1519
|
+
status: 'not_implemented',
|
|
1520
|
+
};
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
export function createToolRegistry(overrides = {}) {
|
|
1524
|
+
return ALL_TOOLS.map((toolName) => {
|
|
1525
|
+
const schema = TOOL_SCHEMAS[toolName] ?? { type: 'object', properties: {} };
|
|
1526
|
+
return {
|
|
1527
|
+
name: toolName,
|
|
1528
|
+
description: TOOL_DESCRIPTIONS[toolName] ?? `Execute ${toolName}`,
|
|
1529
|
+
inputSchema: schema,
|
|
1530
|
+
handler: overrides[toolName] ?? createDefaultHandler(toolName),
|
|
1531
|
+
};
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
export async function routeToolCall(tools, toolName, input) {
|
|
1535
|
+
const tool = tools.find((candidate) => candidate.name === toolName);
|
|
1536
|
+
if (!tool) {
|
|
1537
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
1538
|
+
}
|
|
1539
|
+
return tool.handler(isRecord(input) ? input : {});
|
|
1540
|
+
}
|
|
1541
|
+
export function createMCPServer(overrides = {}, options = {}) {
|
|
1542
|
+
const logger = options.logger ?? createDefaultMcpLogger();
|
|
1543
|
+
const v2Handlers = options.captureClient ? createV2ToolHandlers(options.captureClient) : {};
|
|
1544
|
+
const tools = createToolRegistry({
|
|
1545
|
+
...createV1ToolHandlers(() => getConnection().db),
|
|
1546
|
+
...v2Handlers,
|
|
1547
|
+
...overrides,
|
|
1548
|
+
});
|
|
1549
|
+
const server = new Server({
|
|
1550
|
+
name: 'browser-debug-mcp-bridge',
|
|
1551
|
+
version: '1.0.0',
|
|
1552
|
+
}, {
|
|
1553
|
+
capabilities: {
|
|
1554
|
+
tools: {},
|
|
1555
|
+
},
|
|
1556
|
+
});
|
|
1557
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1558
|
+
logger.debug({ component: 'mcp', event: 'list_tools' }, '[MCPServer][MCP] list_tools request');
|
|
1559
|
+
return {
|
|
1560
|
+
tools: tools.map((tool) => ({
|
|
1561
|
+
name: tool.name,
|
|
1562
|
+
description: tool.description,
|
|
1563
|
+
inputSchema: tool.inputSchema,
|
|
1564
|
+
})),
|
|
1565
|
+
};
|
|
1566
|
+
});
|
|
1567
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1568
|
+
const toolName = request.params.name;
|
|
1569
|
+
const startedAt = Date.now();
|
|
1570
|
+
logger.info({ component: 'mcp', event: 'tool_call_started', toolName }, '[MCPServer][MCP] Tool call started');
|
|
1571
|
+
try {
|
|
1572
|
+
const response = await routeToolCall(tools, toolName, request.params.arguments);
|
|
1573
|
+
logger.info({
|
|
1574
|
+
component: 'mcp',
|
|
1575
|
+
event: 'tool_call_completed',
|
|
1576
|
+
toolName,
|
|
1577
|
+
durationMs: Date.now() - startedAt,
|
|
1578
|
+
}, '[MCPServer][MCP] Tool call completed');
|
|
1579
|
+
return {
|
|
1580
|
+
content: [
|
|
1581
|
+
{
|
|
1582
|
+
type: 'text',
|
|
1583
|
+
text: JSON.stringify(response),
|
|
1584
|
+
},
|
|
1585
|
+
],
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
catch (error) {
|
|
1589
|
+
const message = error instanceof Error ? error.message : 'Unknown MCP tool error';
|
|
1590
|
+
logger.error({
|
|
1591
|
+
component: 'mcp',
|
|
1592
|
+
event: 'tool_call_failed',
|
|
1593
|
+
toolName,
|
|
1594
|
+
durationMs: Date.now() - startedAt,
|
|
1595
|
+
error: message,
|
|
1596
|
+
}, '[MCPServer][MCP] Tool call failed');
|
|
1597
|
+
return {
|
|
1598
|
+
isError: true,
|
|
1599
|
+
content: [
|
|
1600
|
+
{
|
|
1601
|
+
type: 'text',
|
|
1602
|
+
text: message,
|
|
1603
|
+
},
|
|
1604
|
+
],
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
const transport = new StdioServerTransport();
|
|
1609
|
+
return {
|
|
1610
|
+
server,
|
|
1611
|
+
transport,
|
|
1612
|
+
tools,
|
|
1613
|
+
start: async () => {
|
|
1614
|
+
await server.connect(transport);
|
|
1615
|
+
},
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
export { createBaseResponse };
|
|
1619
|
+
//# sourceMappingURL=server.js.map
|