@runtimescope/mcp-server 0.6.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/dist/index.js ADDED
@@ -0,0 +1,4690 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { execSync as execSync2 } from "child_process";
5
+ import { existsSync as existsSync2 } from "fs";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import {
9
+ CollectorServer,
10
+ ProjectManager,
11
+ ApiDiscoveryEngine,
12
+ ProcessMonitor,
13
+ InfraConnector,
14
+ ConnectionManager,
15
+ SchemaIntrospector,
16
+ DataBrowser,
17
+ SessionManager,
18
+ HttpServer,
19
+ SqliteStore,
20
+ AuthManager,
21
+ Redactor,
22
+ resolveTlsConfig
23
+ } from "@runtimescope/collector";
24
+
25
+ // src/tools/network.ts
26
+ import { z } from "zod";
27
+ function registerNetworkTools(server, store) {
28
+ server.tool(
29
+ "get_network_requests",
30
+ "Get captured network (fetch) requests from the running web app. Returns URL, method, status, timing, and optional GraphQL operation info.",
31
+ {
32
+ since_seconds: z.number().optional().describe("Only return requests from the last N seconds"),
33
+ url_pattern: z.string().optional().describe("Filter by URL substring match"),
34
+ status: z.number().optional().describe("Filter by HTTP status code"),
35
+ method: z.string().optional().describe("Filter by HTTP method (GET, POST, etc.)")
36
+ },
37
+ async ({ since_seconds, url_pattern, status, method }) => {
38
+ const events = store.getNetworkRequests({
39
+ sinceSeconds: since_seconds,
40
+ urlPattern: url_pattern,
41
+ status,
42
+ method
43
+ });
44
+ const timeRange = events.length > 0 ? { from: events[events.length - 1].timestamp, to: events[0].timestamp } : { from: 0, to: 0 };
45
+ const sessions = store.getSessionInfo();
46
+ const sessionId = sessions[0]?.sessionId ?? null;
47
+ const failedCount = events.filter((e) => e.status >= 400).length;
48
+ const avgDuration = events.length > 0 ? (events.reduce((s, e) => s + e.duration, 0) / events.length).toFixed(0) : "0";
49
+ const issues = [];
50
+ if (failedCount > 0) issues.push(`${failedCount} failed request(s) (4xx/5xx)`);
51
+ const slowRequests = events.filter((e) => e.duration > 3e3);
52
+ if (slowRequests.length > 0) issues.push(`${slowRequests.length} slow request(s) (>3s)`);
53
+ const urlCounts = /* @__PURE__ */ new Map();
54
+ for (const e of events) {
55
+ const key = `${e.method} ${e.url}`;
56
+ const existing = urlCounts.get(key);
57
+ if (existing) {
58
+ existing.count++;
59
+ existing.last = Math.max(existing.last, e.timestamp);
60
+ existing.first = Math.min(existing.first, e.timestamp);
61
+ } else {
62
+ urlCounts.set(key, { count: 1, first: e.timestamp, last: e.timestamp });
63
+ }
64
+ }
65
+ for (const [key, info] of urlCounts) {
66
+ if (info.count > 5 && info.last - info.first < 2e3) {
67
+ issues.push(`Possible N+1: ${key} called ${info.count} times in ${((info.last - info.first) / 1e3).toFixed(1)}s`);
68
+ }
69
+ }
70
+ const response = {
71
+ summary: `Found ${events.length} network request(s)${since_seconds ? ` in the last ${since_seconds}s` : ""}. Average duration: ${avgDuration}ms.`,
72
+ data: events.map((e) => ({
73
+ url: e.url,
74
+ method: e.method,
75
+ status: e.status,
76
+ duration: `${e.duration.toFixed(0)}ms`,
77
+ ttfb: `${e.ttfb.toFixed(0)}ms`,
78
+ requestBodySize: e.requestBodySize,
79
+ responseBodySize: e.responseBodySize,
80
+ graphqlOperation: e.graphqlOperation ?? null,
81
+ timestamp: new Date(e.timestamp).toISOString()
82
+ })),
83
+ issues,
84
+ metadata: { timeRange, eventCount: events.length, sessionId }
85
+ };
86
+ return {
87
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
88
+ };
89
+ }
90
+ );
91
+ }
92
+
93
+ // src/tools/console.ts
94
+ import { z as z2 } from "zod";
95
+ function registerConsoleTools(server, store) {
96
+ server.tool(
97
+ "get_console_messages",
98
+ "Get captured console messages (log, warn, error, info, debug, trace) from the running web app. Includes message text, args, and stack traces for errors.",
99
+ {
100
+ level: z2.enum(["log", "warn", "error", "info", "debug", "trace"]).optional().describe("Filter by console level"),
101
+ since_seconds: z2.number().optional().describe("Only return messages from the last N seconds"),
102
+ search: z2.string().optional().describe("Search message text (case-insensitive substring match)")
103
+ },
104
+ async ({ level, since_seconds, search }) => {
105
+ const events = store.getConsoleMessages({
106
+ level,
107
+ sinceSeconds: since_seconds,
108
+ search
109
+ });
110
+ const timeRange = events.length > 0 ? { from: events[events.length - 1].timestamp, to: events[0].timestamp } : { from: 0, to: 0 };
111
+ const sessions = store.getSessionInfo();
112
+ const sessionId = sessions[0]?.sessionId ?? null;
113
+ const levelCounts = {};
114
+ for (const e of events) {
115
+ levelCounts[e.level] = (levelCounts[e.level] || 0) + 1;
116
+ }
117
+ const levelSummary = Object.entries(levelCounts).map(([l, c]) => `${c} ${l}`).join(", ");
118
+ const issues = [];
119
+ const errorMessages = /* @__PURE__ */ new Map();
120
+ for (const e of events) {
121
+ if (e.level === "error") {
122
+ const existing = errorMessages.get(e.message);
123
+ if (existing) {
124
+ existing.count++;
125
+ existing.last = Math.max(existing.last, e.timestamp);
126
+ existing.first = Math.min(existing.first, e.timestamp);
127
+ } else {
128
+ errorMessages.set(e.message, { count: 1, first: e.timestamp, last: e.timestamp });
129
+ }
130
+ }
131
+ }
132
+ for (const [msg, info] of errorMessages) {
133
+ if (info.count > 5 && info.last - info.first < 1e4) {
134
+ const truncated = msg.length > 80 ? msg.slice(0, 80) + "..." : msg;
135
+ issues.push(`Error spam: "${truncated}" repeated ${info.count} times in ${((info.last - info.first) / 1e3).toFixed(1)}s`);
136
+ }
137
+ }
138
+ const response = {
139
+ summary: `Found ${events.length} console message(s)${since_seconds ? ` in the last ${since_seconds}s` : ""}${levelSummary ? `. Breakdown: ${levelSummary}` : ""}.`,
140
+ data: events.map((e) => ({
141
+ level: e.level,
142
+ message: e.message,
143
+ args: e.args,
144
+ stackTrace: e.stackTrace ?? null,
145
+ sourceFile: e.sourceFile ?? null,
146
+ timestamp: new Date(e.timestamp).toISOString()
147
+ })),
148
+ issues,
149
+ metadata: { timeRange, eventCount: events.length, sessionId }
150
+ };
151
+ return {
152
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
153
+ };
154
+ }
155
+ );
156
+ }
157
+
158
+ // src/tools/session.ts
159
+ function registerSessionTools(server, store) {
160
+ server.tool(
161
+ "get_session_info",
162
+ "Get information about connected browser sessions and overall event statistics. Use this to check if the SDK is connected.",
163
+ {},
164
+ async () => {
165
+ const sessions = store.getSessionInfo();
166
+ const response = {
167
+ summary: sessions.length > 0 ? `${sessions.length} session(s) connected. Total events captured: ${store.eventCount}.` : "No active sessions. Make sure the RuntimeScope SDK is injected in your app and connected to ws://localhost:9090.",
168
+ data: sessions.map((s) => ({
169
+ sessionId: s.sessionId,
170
+ appName: s.appName,
171
+ sdkVersion: s.sdkVersion,
172
+ connectedAt: new Date(s.connectedAt).toISOString(),
173
+ eventCount: s.eventCount,
174
+ isConnected: s.isConnected
175
+ })),
176
+ issues: sessions.length === 0 ? ["No SDK connections detected"] : [],
177
+ metadata: {
178
+ timeRange: { from: 0, to: Date.now() },
179
+ eventCount: store.eventCount,
180
+ sessionId: sessions[0]?.sessionId ?? null
181
+ }
182
+ };
183
+ return {
184
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
185
+ };
186
+ }
187
+ );
188
+ server.tool(
189
+ "clear_events",
190
+ "Clear all captured events from the buffer. Use this to start a fresh capture session.",
191
+ {},
192
+ async () => {
193
+ const { clearedCount } = store.clear();
194
+ const response = {
195
+ summary: `Cleared ${clearedCount} events. Buffer is now empty.`,
196
+ data: null,
197
+ issues: [],
198
+ metadata: {
199
+ timeRange: { from: 0, to: 0 },
200
+ eventCount: 0,
201
+ sessionId: null
202
+ }
203
+ };
204
+ return {
205
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
206
+ };
207
+ }
208
+ );
209
+ }
210
+
211
+ // src/tools/issues.ts
212
+ import { z as z3 } from "zod";
213
+ import { detectIssues } from "@runtimescope/collector";
214
+ function registerIssueTools(server, store, apiDiscovery, processMonitor) {
215
+ server.tool(
216
+ "detect_issues",
217
+ "Run all pattern detectors against captured runtime data and return prioritized issues. Detects: failed requests, slow requests (>3s), N+1 request patterns, console error spam, high error rates, slow DB queries (>500ms), N+1 DB queries, API degradation, high latency endpoints, orphaned processes, and more. Use this as the first tool when investigating performance problems.",
218
+ {
219
+ since_seconds: z3.number().optional().describe("Analyze events from the last N seconds (default: all events)"),
220
+ severity_filter: z3.enum(["high", "medium", "low"]).optional().describe("Only return issues at this severity or above")
221
+ },
222
+ async ({ since_seconds, severity_filter }) => {
223
+ const events = store.getAllEvents(since_seconds);
224
+ const allIssues = [...detectIssues(events)];
225
+ if (apiDiscovery) {
226
+ try {
227
+ allIssues.push(...apiDiscovery.detectIssues(events));
228
+ } catch {
229
+ }
230
+ }
231
+ if (processMonitor) {
232
+ try {
233
+ allIssues.push(...processMonitor.detectIssues());
234
+ } catch {
235
+ }
236
+ }
237
+ const severityOrder = { high: 0, medium: 1, low: 2 };
238
+ allIssues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
239
+ const filterThreshold = severity_filter ? severityOrder[severity_filter] : 2;
240
+ const issues = allIssues.filter(
241
+ (i) => severityOrder[i.severity] <= filterThreshold
242
+ );
243
+ const sessions = store.getSessionInfo();
244
+ const sessionId = sessions[0]?.sessionId ?? null;
245
+ const highCount = issues.filter((i) => i.severity === "high").length;
246
+ const mediumCount = issues.filter((i) => i.severity === "medium").length;
247
+ const lowCount = issues.filter((i) => i.severity === "low").length;
248
+ const summaryParts = [];
249
+ if (issues.length === 0) {
250
+ summaryParts.push("No issues detected.");
251
+ } else {
252
+ summaryParts.push(`Found ${issues.length} issue(s):`);
253
+ if (highCount > 0) summaryParts.push(`${highCount} HIGH`);
254
+ if (mediumCount > 0) summaryParts.push(`${mediumCount} MEDIUM`);
255
+ if (lowCount > 0) summaryParts.push(`${lowCount} LOW`);
256
+ }
257
+ summaryParts.push(`Analyzed ${events.length} events${since_seconds ? ` from last ${since_seconds}s` : ""}.`);
258
+ const response = {
259
+ summary: summaryParts.join(" "),
260
+ data: issues.map((i) => ({
261
+ severity: i.severity.toUpperCase(),
262
+ pattern: i.pattern,
263
+ title: i.title,
264
+ description: i.description,
265
+ evidence: i.evidence,
266
+ suggestion: i.suggestion ?? null
267
+ })),
268
+ issues: issues.map((i) => `[${i.severity.toUpperCase()}] ${i.title}`),
269
+ metadata: {
270
+ timeRange: {
271
+ from: events.length > 0 ? events[0].timestamp : 0,
272
+ to: events.length > 0 ? events[events.length - 1].timestamp : 0
273
+ },
274
+ eventCount: events.length,
275
+ sessionId
276
+ }
277
+ };
278
+ return {
279
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
280
+ };
281
+ }
282
+ );
283
+ }
284
+
285
+ // src/tools/timeline.ts
286
+ import { z as z4 } from "zod";
287
+ function registerTimelineTools(server, store) {
288
+ server.tool(
289
+ "get_event_timeline",
290
+ "Get a chronological view of ALL events (network requests, console messages) interleaved by timestamp. Essential for understanding causal chains \u2014 e.g. seeing that an API call failed, then an error was logged, then another retry fired. Events are in chronological order (oldest first).",
291
+ {
292
+ since_seconds: z4.number().optional().describe("Only return events from the last N seconds (default: 60)"),
293
+ event_types: z4.array(z4.enum(["network", "console", "session", "state", "render", "performance", "dom_snapshot", "database"])).optional().describe("Filter by event types (default: all)"),
294
+ limit: z4.number().optional().describe("Max events to return (default: 200, max: 1000)")
295
+ },
296
+ async ({ since_seconds, event_types, limit }) => {
297
+ const sinceSeconds = since_seconds ?? 60;
298
+ const maxEvents = Math.min(limit ?? 200, 1e3);
299
+ const events = store.getEventTimeline({
300
+ sinceSeconds,
301
+ eventTypes: event_types
302
+ });
303
+ const trimmed = events.length > maxEvents ? events.slice(events.length - maxEvents) : events;
304
+ const sessions = store.getSessionInfo();
305
+ const sessionId = sessions[0]?.sessionId ?? null;
306
+ const typeCounts = {};
307
+ for (const e of trimmed) {
308
+ typeCounts[e.eventType] = (typeCounts[e.eventType] || 0) + 1;
309
+ }
310
+ const typeBreakdown = Object.entries(typeCounts).map(([t, c]) => `${c} ${t}`).join(", ");
311
+ const response = {
312
+ summary: `Timeline: ${trimmed.length} event(s) in the last ${sinceSeconds}s${events.length > maxEvents ? ` (showing last ${maxEvents} of ${events.length})` : ""}. Breakdown: ${typeBreakdown || "none"}.`,
313
+ data: trimmed.map((e) => formatTimelineEvent(e)),
314
+ issues: [],
315
+ metadata: {
316
+ timeRange: {
317
+ from: trimmed.length > 0 ? trimmed[0].timestamp : 0,
318
+ to: trimmed.length > 0 ? trimmed[trimmed.length - 1].timestamp : 0
319
+ },
320
+ eventCount: trimmed.length,
321
+ totalInWindow: events.length,
322
+ sessionId
323
+ }
324
+ };
325
+ return {
326
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
327
+ };
328
+ }
329
+ );
330
+ }
331
+ function formatTimelineEvent(event) {
332
+ const base = {
333
+ type: event.eventType,
334
+ timestamp: new Date(event.timestamp).toISOString(),
335
+ relativeMs: 0
336
+ // will be set by caller if needed
337
+ };
338
+ switch (event.eventType) {
339
+ case "network": {
340
+ const ne = event;
341
+ return {
342
+ ...base,
343
+ method: ne.method,
344
+ url: ne.url,
345
+ status: ne.status,
346
+ duration: `${ne.duration.toFixed(0)}ms`,
347
+ graphql: ne.graphqlOperation ? `${ne.graphqlOperation.type} ${ne.graphqlOperation.name}` : null
348
+ };
349
+ }
350
+ case "console": {
351
+ const ce = event;
352
+ return {
353
+ ...base,
354
+ level: ce.level,
355
+ message: ce.message.length > 200 ? ce.message.slice(0, 200) + "..." : ce.message,
356
+ hasStack: !!ce.stackTrace
357
+ };
358
+ }
359
+ case "session":
360
+ return {
361
+ ...base,
362
+ note: "SDK session connected"
363
+ };
364
+ case "state": {
365
+ const se = event;
366
+ return {
367
+ ...base,
368
+ storeId: se.storeId,
369
+ library: se.library,
370
+ phase: se.phase,
371
+ action: se.action?.type ?? null,
372
+ changedKeys: se.diff ? Object.keys(se.diff).join(", ") : null
373
+ };
374
+ }
375
+ case "render": {
376
+ const re = event;
377
+ return {
378
+ ...base,
379
+ totalRenders: re.totalRenders,
380
+ componentCount: re.profiles.length,
381
+ suspicious: re.suspiciousComponents.length > 0 ? re.suspiciousComponents.join(", ") : null
382
+ };
383
+ }
384
+ case "performance": {
385
+ const pe = event;
386
+ return {
387
+ ...base,
388
+ metric: pe.metricName,
389
+ value: pe.value,
390
+ rating: pe.rating,
391
+ element: pe.element ?? null
392
+ };
393
+ }
394
+ case "dom_snapshot": {
395
+ const ds = event;
396
+ return {
397
+ ...base,
398
+ url: ds.url,
399
+ elementCount: ds.elementCount,
400
+ htmlSize: `${Math.round(ds.html.length / 1024)}KB`,
401
+ truncated: ds.truncated
402
+ };
403
+ }
404
+ case "database": {
405
+ const de = event;
406
+ return {
407
+ ...base,
408
+ operation: de.operation,
409
+ query: de.query.length > 150 ? de.query.slice(0, 150) + "..." : de.query,
410
+ duration: `${de.duration.toFixed(0)}ms`,
411
+ tables: de.tablesAccessed,
412
+ source: de.source,
413
+ error: de.error ?? null
414
+ };
415
+ }
416
+ default:
417
+ return base;
418
+ }
419
+ }
420
+
421
+ // src/tools/state.ts
422
+ import { z as z5 } from "zod";
423
+ function registerStateTools(server, store) {
424
+ server.tool(
425
+ "get_state_snapshots",
426
+ "Get state store snapshots and diffs from Zustand or Redux stores. Shows state changes over time with action history, mutation frequency, and shallow diffs showing which keys changed.",
427
+ {
428
+ store_name: z5.string().optional().describe("Filter by store name/ID"),
429
+ since_seconds: z5.number().optional().describe("Only return events from the last N seconds")
430
+ },
431
+ async ({ store_name, since_seconds }) => {
432
+ const events = store.getStateEvents({
433
+ storeId: store_name,
434
+ sinceSeconds: since_seconds
435
+ });
436
+ const sessions = store.getSessionInfo();
437
+ const sessionId = sessions[0]?.sessionId ?? null;
438
+ const issues = [];
439
+ const storeUpdates = /* @__PURE__ */ new Map();
440
+ for (const e of events) {
441
+ if (e.phase !== "update") continue;
442
+ const timestamps = storeUpdates.get(e.storeId) ?? [];
443
+ timestamps.push(e.timestamp);
444
+ storeUpdates.set(e.storeId, timestamps);
445
+ }
446
+ for (const [storeId, timestamps] of storeUpdates) {
447
+ if (timestamps.length < 10) continue;
448
+ for (let i = 0; i <= timestamps.length - 10; i++) {
449
+ if (timestamps[i + 9] - timestamps[i] < 1e3) {
450
+ issues.push(`Store thrashing: "${storeId}" had ${timestamps.length} updates, 10+ in a 1-second window`);
451
+ break;
452
+ }
453
+ }
454
+ }
455
+ const response = {
456
+ summary: `Found ${events.length} state event(s)${since_seconds ? ` in the last ${since_seconds}s` : ""}${store_name ? ` for store "${store_name}"` : ""}.`,
457
+ data: events.map((e) => ({
458
+ storeId: e.storeId,
459
+ library: e.library,
460
+ phase: e.phase,
461
+ state: e.state,
462
+ previousState: e.previousState ?? null,
463
+ diff: e.diff ?? null,
464
+ action: e.action ?? null,
465
+ timestamp: new Date(e.timestamp).toISOString()
466
+ })),
467
+ issues,
468
+ metadata: {
469
+ timeRange: {
470
+ from: events.length > 0 ? events[0].timestamp : 0,
471
+ to: events.length > 0 ? events[events.length - 1].timestamp : 0
472
+ },
473
+ eventCount: events.length,
474
+ sessionId
475
+ }
476
+ };
477
+ return {
478
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
479
+ };
480
+ }
481
+ );
482
+ }
483
+
484
+ // src/tools/renders.ts
485
+ import { z as z6 } from "zod";
486
+ function registerRenderTools(server, store) {
487
+ server.tool(
488
+ "get_render_profile",
489
+ "Get React component render profiles showing render counts, velocity, average duration, and render causes. Flags suspicious components that are re-rendering excessively. Requires captureRenders: true in the SDK config and React dev mode for accurate timing data.",
490
+ {
491
+ component_name: z6.string().optional().describe("Filter by component name (substring match)"),
492
+ since_seconds: z6.number().optional().describe("Only return events from the last N seconds")
493
+ },
494
+ async ({ component_name, since_seconds }) => {
495
+ const events = store.getRenderEvents({
496
+ componentName: component_name,
497
+ sinceSeconds: since_seconds
498
+ });
499
+ const sessions = store.getSessionInfo();
500
+ const sessionId = sessions[0]?.sessionId ?? null;
501
+ const issues = [];
502
+ const merged = /* @__PURE__ */ new Map();
503
+ const allSuspicious = /* @__PURE__ */ new Set();
504
+ for (const event of events) {
505
+ for (const profile of event.profiles) {
506
+ const existing = merged.get(profile.componentName);
507
+ if (existing) {
508
+ existing.renderCount += profile.renderCount;
509
+ existing.totalDuration += profile.totalDuration;
510
+ existing.avgDuration = existing.renderCount > 0 ? existing.totalDuration / existing.renderCount : 0;
511
+ existing.renderVelocity = Math.max(existing.renderVelocity, profile.renderVelocity);
512
+ existing.lastRenderPhase = profile.lastRenderPhase;
513
+ existing.lastRenderCause = profile.lastRenderCause;
514
+ if (profile.suspicious) existing.suspicious = true;
515
+ } else {
516
+ merged.set(profile.componentName, { ...profile });
517
+ }
518
+ if (profile.suspicious) {
519
+ allSuspicious.add(profile.componentName);
520
+ }
521
+ }
522
+ }
523
+ if (allSuspicious.size > 0) {
524
+ issues.push(`${allSuspicious.size} suspicious component(s): ${Array.from(allSuspicious).join(", ")}`);
525
+ }
526
+ const profiles = Array.from(merged.values()).sort(
527
+ (a, b) => b.renderCount - a.renderCount
528
+ );
529
+ const totalRenders = profiles.reduce((s, p) => s + p.renderCount, 0);
530
+ const response = {
531
+ summary: `${profiles.length} component(s) tracked, ${totalRenders} total renders${since_seconds ? ` in the last ${since_seconds}s` : ""}. ${allSuspicious.size} suspicious.`,
532
+ data: profiles.map((p) => ({
533
+ componentName: p.componentName,
534
+ renderCount: p.renderCount,
535
+ totalDuration: `${p.totalDuration.toFixed(1)}ms`,
536
+ avgDuration: `${p.avgDuration.toFixed(1)}ms`,
537
+ renderVelocity: `${p.renderVelocity.toFixed(1)}/sec`,
538
+ lastRenderPhase: p.lastRenderPhase,
539
+ lastRenderCause: p.lastRenderCause,
540
+ suspicious: p.suspicious
541
+ })),
542
+ issues,
543
+ metadata: {
544
+ timeRange: {
545
+ from: events.length > 0 ? events[0].timestamp : 0,
546
+ to: events.length > 0 ? events[events.length - 1].timestamp : 0
547
+ },
548
+ eventCount: events.length,
549
+ sessionId
550
+ }
551
+ };
552
+ return {
553
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
554
+ };
555
+ }
556
+ );
557
+ }
558
+
559
+ // src/tools/performance.ts
560
+ import { z as z7 } from "zod";
561
+ var WEB_VITAL_METRICS = ["LCP", "FCP", "CLS", "TTFB", "FID", "INP"];
562
+ var SERVER_METRICS = [
563
+ "memory.rss",
564
+ "memory.heapUsed",
565
+ "memory.heapTotal",
566
+ "memory.external",
567
+ "eventloop.lag.mean",
568
+ "eventloop.lag.p99",
569
+ "eventloop.lag.max",
570
+ "gc.pause.major",
571
+ "gc.pause.minor",
572
+ "cpu.user",
573
+ "cpu.system",
574
+ "handles.active",
575
+ "requests.active"
576
+ ];
577
+ var ALL_METRICS = [...WEB_VITAL_METRICS, ...SERVER_METRICS];
578
+ function isWebVital(name) {
579
+ return WEB_VITAL_METRICS.includes(name);
580
+ }
581
+ function registerPerformanceTools(server, store) {
582
+ server.tool(
583
+ "get_performance_metrics",
584
+ "Get performance metrics from browser (Web Vitals: LCP, FCP, CLS, TTFB, FID, INP) and/or server (memory, event loop lag, GC pauses, CPU usage). Browser metrics include quality ratings. Server metrics require capturePerformance: true in the server-SDK config.",
585
+ {
586
+ metric_name: z7.enum(ALL_METRICS).optional().describe("Filter by specific metric name"),
587
+ source: z7.enum(["browser", "server", "all"]).optional().default("all").describe("Filter by metric source: browser (Web Vitals), server (Node.js runtime), or all"),
588
+ since_seconds: z7.number().optional().describe("Only return metrics from the last N seconds")
589
+ },
590
+ async ({ metric_name, source, since_seconds }) => {
591
+ let events = store.getPerformanceMetrics({
592
+ metricName: metric_name,
593
+ sinceSeconds: since_seconds
594
+ });
595
+ if (source === "browser") {
596
+ events = events.filter((e) => isWebVital(e.metricName));
597
+ } else if (source === "server") {
598
+ events = events.filter((e) => !isWebVital(e.metricName));
599
+ }
600
+ const sessions = store.getSessionInfo();
601
+ const sessionId = sessions[0]?.sessionId ?? null;
602
+ const issues = [];
603
+ const poor = events.filter((e) => e.rating === "poor");
604
+ const needsImprovement = events.filter((e) => e.rating === "needs-improvement");
605
+ if (poor.length > 0) {
606
+ issues.push(`${poor.length} metric(s) rated "poor": ${poor.map((e) => e.metricName).join(", ")}`);
607
+ }
608
+ if (needsImprovement.length > 0) {
609
+ issues.push(`${needsImprovement.length} metric(s) need improvement: ${needsImprovement.map((e) => e.metricName).join(", ")}`);
610
+ }
611
+ const highMemory = events.filter((e) => e.metricName === "memory.heapUsed" && e.value > 500 * 1024 * 1024);
612
+ if (highMemory.length > 0) {
613
+ issues.push(`Heap usage exceeded 500MB in ${highMemory.length} sample(s)`);
614
+ }
615
+ const highEventLoop = events.filter((e) => e.metricName === "eventloop.lag.p99" && e.value > 100);
616
+ if (highEventLoop.length > 0) {
617
+ issues.push(`Event loop p99 lag exceeded 100ms in ${highEventLoop.length} sample(s)`);
618
+ }
619
+ const latest = /* @__PURE__ */ new Map();
620
+ for (const e of events) {
621
+ latest.set(e.metricName, e);
622
+ }
623
+ const browserMetrics = Array.from(latest.values()).filter((e) => isWebVital(e.metricName));
624
+ const serverMetrics = Array.from(latest.values()).filter((e) => !isWebVital(e.metricName));
625
+ const formatMetric = (e) => ({
626
+ metricName: e.metricName,
627
+ value: e.value,
628
+ unit: e.unit ?? (e.metricName === "CLS" ? "score" : "ms"),
629
+ rating: e.rating ?? null,
630
+ element: e.element ?? null,
631
+ timestamp: new Date(e.timestamp).toISOString()
632
+ });
633
+ const response = {
634
+ summary: `${latest.size} unique metric(s) captured (${browserMetrics.length} browser, ${serverMetrics.length} server). ${poor.length} poor, ${needsImprovement.length} needs improvement.`,
635
+ data: {
636
+ browser: browserMetrics.map(formatMetric),
637
+ server: serverMetrics.map(formatMetric)
638
+ },
639
+ allEvents: events.map((e) => ({
640
+ metricName: e.metricName,
641
+ value: e.value,
642
+ unit: e.unit ?? (e.metricName === "CLS" ? "score" : "ms"),
643
+ rating: e.rating ?? null,
644
+ timestamp: new Date(e.timestamp).toISOString()
645
+ })),
646
+ issues,
647
+ metadata: {
648
+ timeRange: {
649
+ from: events.length > 0 ? events[0].timestamp : 0,
650
+ to: events.length > 0 ? events[events.length - 1].timestamp : 0
651
+ },
652
+ eventCount: events.length,
653
+ sessionId
654
+ }
655
+ };
656
+ return {
657
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
658
+ };
659
+ }
660
+ );
661
+ }
662
+
663
+ // src/tools/dom-snapshot.ts
664
+ import { z as z8 } from "zod";
665
+ function generateRequestId() {
666
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
667
+ }
668
+ function registerDomSnapshotTools(server, store, collector) {
669
+ server.tool(
670
+ "get_dom_snapshot",
671
+ "Capture a live DOM snapshot from the running web app. Sends a command to the SDK which serializes document.documentElement.outerHTML and returns it along with the current URL, viewport dimensions, scroll position, and element count. Useful for understanding what the user sees.",
672
+ {
673
+ max_size: z8.number().optional().describe("Maximum HTML size in bytes (default: 500000). Larger pages will be truncated.")
674
+ },
675
+ async ({ max_size }) => {
676
+ const sessions = store.getSessionInfo();
677
+ const sessionId = collector.getFirstSessionId();
678
+ const activeSession = sessions[0] ?? null;
679
+ if (!sessionId || !activeSession?.isConnected) {
680
+ return {
681
+ content: [{
682
+ type: "text",
683
+ text: JSON.stringify({
684
+ summary: "No active SDK session connected. Ensure the SDK is running in the browser.",
685
+ data: null,
686
+ issues: ["No active session"],
687
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
688
+ }, null, 2)
689
+ }]
690
+ };
691
+ }
692
+ try {
693
+ const requestId = generateRequestId();
694
+ const result = await collector.sendCommand(sessionId, {
695
+ command: "capture_dom_snapshot",
696
+ requestId,
697
+ params: { maxSize: max_size ?? 5e5 }
698
+ }, 1e4);
699
+ const response = {
700
+ summary: `DOM snapshot captured from ${result.url}. ${result.elementCount} elements, ${Math.round(result.html.length / 1024)}KB HTML${result.truncated ? " (truncated)" : ""}.`,
701
+ data: {
702
+ html: result.html,
703
+ url: result.url,
704
+ viewport: result.viewport,
705
+ scrollPosition: result.scrollPosition,
706
+ elementCount: result.elementCount,
707
+ truncated: result.truncated
708
+ },
709
+ issues: result.truncated ? ["HTML was truncated due to size limit"] : [],
710
+ metadata: {
711
+ timeRange: { from: Date.now(), to: Date.now() },
712
+ eventCount: 1,
713
+ sessionId
714
+ }
715
+ };
716
+ return {
717
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
718
+ };
719
+ } catch (err) {
720
+ const errorMsg = err instanceof Error ? err.message : String(err);
721
+ return {
722
+ content: [{
723
+ type: "text",
724
+ text: JSON.stringify({
725
+ summary: `Failed to capture DOM snapshot: ${errorMsg}`,
726
+ data: null,
727
+ issues: [errorMsg],
728
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
729
+ }, null, 2)
730
+ }]
731
+ };
732
+ }
733
+ }
734
+ );
735
+ }
736
+
737
+ // src/tools/har.ts
738
+ import { z as z9 } from "zod";
739
+ function registerHarTools(server, store) {
740
+ server.tool(
741
+ "capture_har",
742
+ "Export captured network requests as a HAR (HTTP Archive) 1.2 JSON file. This is the standard format used by browser DevTools, Charles Proxy, and other tools. Includes request/response headers, body content (if captureBody was enabled in the SDK), and timing data.",
743
+ {
744
+ since_seconds: z9.number().optional().describe("Only include requests from the last N seconds")
745
+ },
746
+ async ({ since_seconds }) => {
747
+ const events = store.getNetworkRequests({
748
+ sinceSeconds: since_seconds
749
+ });
750
+ const sessions = store.getSessionInfo();
751
+ const sessionId = sessions[0]?.sessionId ?? null;
752
+ const har = buildHar(events);
753
+ const response = {
754
+ summary: `HAR export: ${events.length} request(s)${since_seconds ? ` from the last ${since_seconds}s` : ""}. Import into Chrome DevTools or any HAR viewer.`,
755
+ data: har,
756
+ issues: [],
757
+ metadata: {
758
+ timeRange: {
759
+ from: events.length > 0 ? events[0].timestamp : 0,
760
+ to: events.length > 0 ? events[events.length - 1].timestamp : 0
761
+ },
762
+ eventCount: events.length,
763
+ sessionId
764
+ }
765
+ };
766
+ return {
767
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
768
+ };
769
+ }
770
+ );
771
+ }
772
+ function buildHar(events) {
773
+ const entries = events.map((e) => {
774
+ const queryString = parseQueryString(e.url);
775
+ const requestHeaders = Object.entries(e.requestHeaders).map(([name, value]) => ({
776
+ name,
777
+ value
778
+ }));
779
+ const responseHeaders = Object.entries(e.responseHeaders).map(([name, value]) => ({
780
+ name,
781
+ value
782
+ }));
783
+ const contentType = e.responseHeaders["content-type"] ?? "application/octet-stream";
784
+ const entry = {
785
+ startedDateTime: new Date(e.timestamp).toISOString(),
786
+ time: Math.round(e.duration),
787
+ request: {
788
+ method: e.method,
789
+ url: e.url,
790
+ httpVersion: "HTTP/1.1",
791
+ headers: requestHeaders,
792
+ queryString,
793
+ headersSize: -1,
794
+ bodySize: e.requestBodySize
795
+ },
796
+ response: {
797
+ status: e.status,
798
+ statusText: statusText(e.status),
799
+ httpVersion: "HTTP/1.1",
800
+ headers: responseHeaders,
801
+ content: {
802
+ size: e.responseBodySize,
803
+ mimeType: contentType,
804
+ ...e.responseBody ? { text: e.responseBody } : {}
805
+ },
806
+ headersSize: -1,
807
+ bodySize: e.responseBodySize
808
+ },
809
+ timings: {
810
+ send: 0,
811
+ wait: Math.round(e.ttfb),
812
+ receive: Math.max(0, Math.round(e.duration - e.ttfb))
813
+ }
814
+ };
815
+ if (e.requestBody) {
816
+ const reqContentType = e.requestHeaders["content-type"] ?? "application/octet-stream";
817
+ entry.request.postData = {
818
+ mimeType: reqContentType,
819
+ text: e.requestBody
820
+ };
821
+ }
822
+ return entry;
823
+ });
824
+ return {
825
+ log: {
826
+ version: "1.2",
827
+ creator: {
828
+ name: "RuntimeScope",
829
+ version: "0.2.0"
830
+ },
831
+ entries
832
+ }
833
+ };
834
+ }
835
+ function parseQueryString(url) {
836
+ try {
837
+ const parsed = new URL(url);
838
+ return Array.from(parsed.searchParams.entries()).map(([name, value]) => ({
839
+ name,
840
+ value
841
+ }));
842
+ } catch {
843
+ return [];
844
+ }
845
+ }
846
+ function statusText(status) {
847
+ const texts = {
848
+ 200: "OK",
849
+ 201: "Created",
850
+ 204: "No Content",
851
+ 301: "Moved Permanently",
852
+ 302: "Found",
853
+ 304: "Not Modified",
854
+ 400: "Bad Request",
855
+ 401: "Unauthorized",
856
+ 403: "Forbidden",
857
+ 404: "Not Found",
858
+ 405: "Method Not Allowed",
859
+ 409: "Conflict",
860
+ 422: "Unprocessable Entity",
861
+ 429: "Too Many Requests",
862
+ 500: "Internal Server Error",
863
+ 502: "Bad Gateway",
864
+ 503: "Service Unavailable",
865
+ 504: "Gateway Timeout"
866
+ };
867
+ return texts[status] ?? "";
868
+ }
869
+
870
+ // src/tools/errors.ts
871
+ import { z as z10 } from "zod";
872
+ function registerErrorTools(server, store) {
873
+ server.tool(
874
+ "get_errors_with_source_context",
875
+ "Get console errors with parsed stack traces and surrounding source code lines. Fetches the actual source files from the dev server (e.g. http://localhost:3000/src/...) and shows the lines around the error. This gives you the same context as clicking a stack frame in DevTools.",
876
+ {
877
+ since_seconds: z10.number().optional().describe("Only return errors from the last N seconds"),
878
+ fetch_source: z10.boolean().optional().describe("Whether to fetch source files for context (default: true). Set false for faster results."),
879
+ context_lines: z10.number().optional().describe("Number of source lines to show above and below the error line (default: 5)")
880
+ },
881
+ async ({ since_seconds, fetch_source, context_lines }) => {
882
+ const shouldFetch = fetch_source !== false;
883
+ const contextSize = context_lines ?? 5;
884
+ const events = store.getConsoleMessages({
885
+ level: "error",
886
+ sinceSeconds: since_seconds
887
+ });
888
+ const sessions = store.getSessionInfo();
889
+ const sessionId = sessions[0]?.sessionId ?? null;
890
+ const limited = events.slice(0, 50);
891
+ const sourceCache = /* @__PURE__ */ new Map();
892
+ const errors = [];
893
+ for (const event of limited) {
894
+ const frames = event.stackTrace ? parseStackTrace(event.stackTrace) : [];
895
+ if (shouldFetch) {
896
+ for (const frame of frames) {
897
+ if (frame.file.includes("node_modules")) continue;
898
+ if (!frame.file.startsWith("http")) continue;
899
+ if (!sourceCache.has(frame.file)) {
900
+ sourceCache.set(frame.file, await fetchSource(frame.file));
901
+ }
902
+ const source = sourceCache.get(frame.file);
903
+ if (source) {
904
+ frame.sourceContext = extractContext(source, frame.line, contextSize);
905
+ }
906
+ }
907
+ }
908
+ errors.push({
909
+ message: event.message,
910
+ timestamp: new Date(event.timestamp).toISOString(),
911
+ frames
912
+ });
913
+ }
914
+ const issues = [];
915
+ if (events.length > 50) {
916
+ issues.push(`Showing 50 of ${events.length} errors`);
917
+ }
918
+ const uniqueMessages = new Set(limited.map((e) => e.message.slice(0, 100)));
919
+ const response = {
920
+ summary: `${limited.length} error(s)${since_seconds ? ` in the last ${since_seconds}s` : ""}, ${uniqueMessages.size} unique. ${shouldFetch ? "Source context included." : "Source context disabled."}`,
921
+ data: errors,
922
+ issues,
923
+ metadata: {
924
+ timeRange: {
925
+ from: limited.length > 0 ? limited[0].timestamp : 0,
926
+ to: limited.length > 0 ? limited[limited.length - 1].timestamp : 0
927
+ },
928
+ eventCount: limited.length,
929
+ sessionId
930
+ }
931
+ };
932
+ return {
933
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
934
+ };
935
+ }
936
+ );
937
+ }
938
+ function parseStackTrace(stack) {
939
+ const frames = [];
940
+ for (const line of stack.split("\n")) {
941
+ const trimmed = line.trim();
942
+ const chromeMatch = trimmed.match(
943
+ /^at\s+(?:(.+?)\s+\()?(https?:\/\/[^)]+):(\d+):(\d+)\)?$/
944
+ );
945
+ if (chromeMatch) {
946
+ frames.push({
947
+ functionName: chromeMatch[1] ?? "<anonymous>",
948
+ file: chromeMatch[2],
949
+ line: parseInt(chromeMatch[3], 10),
950
+ column: parseInt(chromeMatch[4], 10)
951
+ });
952
+ continue;
953
+ }
954
+ const firefoxMatch = trimmed.match(
955
+ /^(.+?)@(https?:\/\/.+):(\d+):(\d+)$/
956
+ );
957
+ if (firefoxMatch) {
958
+ frames.push({
959
+ functionName: firefoxMatch[1] ?? "<anonymous>",
960
+ file: firefoxMatch[2],
961
+ line: parseInt(firefoxMatch[3], 10),
962
+ column: parseInt(firefoxMatch[4], 10)
963
+ });
964
+ }
965
+ }
966
+ return frames;
967
+ }
968
+ async function fetchSource(url) {
969
+ try {
970
+ const controller = new AbortController();
971
+ const timeout = setTimeout(() => controller.abort(), 2e3);
972
+ const response = await fetch(url, { signal: controller.signal });
973
+ clearTimeout(timeout);
974
+ if (!response.ok) return null;
975
+ return await response.text();
976
+ } catch {
977
+ return null;
978
+ }
979
+ }
980
+ function extractContext(source, targetLine, contextSize) {
981
+ const lines = source.split("\n");
982
+ const start = Math.max(0, targetLine - contextSize - 1);
983
+ const end = Math.min(lines.length, targetLine + contextSize);
984
+ return lines.slice(start, end).map((line, i) => {
985
+ const lineNum = start + i + 1;
986
+ const marker = lineNum === targetLine ? ">>>" : " ";
987
+ return `${marker} ${lineNum.toString().padStart(4)} | ${line}`;
988
+ });
989
+ }
990
+
991
+ // src/tools/api-discovery.ts
992
+ import { z as z11 } from "zod";
993
+ function registerApiDiscoveryTools(server, store, engine) {
994
+ server.tool(
995
+ "get_api_catalog",
996
+ "Discover all API endpoints the app is communicating with, auto-grouped by service. Shows normalized paths, call counts, auth patterns, and inferred response shapes.",
997
+ {
998
+ service: z11.string().optional().describe('Filter by service name (e.g. "Supabase", "Your API")'),
999
+ min_calls: z11.number().optional().describe("Only show endpoints with at least N calls")
1000
+ },
1001
+ async ({ service, min_calls }) => {
1002
+ const catalog = engine.getCatalog({ service, minCalls: min_calls });
1003
+ const services = engine.getServiceMap();
1004
+ const sessions = store.getSessionInfo();
1005
+ const sessionId = sessions[0]?.sessionId ?? null;
1006
+ const response = {
1007
+ summary: `Discovered ${catalog.length} API endpoint(s) across ${services.length} service(s).`,
1008
+ data: {
1009
+ services: services.map((s) => ({
1010
+ name: s.name,
1011
+ baseUrl: s.baseUrl,
1012
+ endpointCount: s.endpointCount,
1013
+ totalCalls: s.totalCalls,
1014
+ avgLatency: `${s.avgLatency.toFixed(0)}ms`,
1015
+ errorRate: `${(s.errorRate * 100).toFixed(1)}%`,
1016
+ auth: s.auth.type,
1017
+ platform: s.detectedPlatform ?? null
1018
+ })),
1019
+ endpoints: catalog.map((ep) => ({
1020
+ method: ep.method,
1021
+ path: ep.normalizedPath,
1022
+ service: ep.service,
1023
+ callCount: ep.callCount,
1024
+ auth: ep.auth.type,
1025
+ firstSeen: new Date(ep.firstSeen).toISOString(),
1026
+ lastSeen: new Date(ep.lastSeen).toISOString(),
1027
+ graphql: ep.graphqlOperation ?? null,
1028
+ responseFields: ep.contract?.responseFields.length ?? 0
1029
+ }))
1030
+ },
1031
+ issues: [],
1032
+ metadata: {
1033
+ timeRange: catalog.length > 0 ? { from: Math.min(...catalog.map((e) => e.firstSeen)), to: Math.max(...catalog.map((e) => e.lastSeen)) } : { from: 0, to: 0 },
1034
+ eventCount: catalog.reduce((s, e) => s + e.callCount, 0),
1035
+ sessionId
1036
+ }
1037
+ };
1038
+ return {
1039
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1040
+ };
1041
+ }
1042
+ );
1043
+ server.tool(
1044
+ "get_api_health",
1045
+ "Get health metrics for discovered API endpoints: success rate, latency percentiles (p50/p95), error rates and error codes.",
1046
+ {
1047
+ endpoint: z11.string().optional().describe("Filter by endpoint path substring"),
1048
+ since_seconds: z11.number().optional().describe("Only consider requests from the last N seconds")
1049
+ },
1050
+ async ({ endpoint, since_seconds }) => {
1051
+ const health = engine.getHealth({ endpoint, sinceSeconds: since_seconds });
1052
+ const sessions = store.getSessionInfo();
1053
+ const sessionId = sessions[0]?.sessionId ?? null;
1054
+ const issues = [];
1055
+ for (const ep of health) {
1056
+ if (ep.errorRate > 0.5) issues.push(`${ep.method} ${ep.normalizedPath}: ${(ep.errorRate * 100).toFixed(0)}% error rate`);
1057
+ if (ep.p95Latency > 5e3) issues.push(`${ep.method} ${ep.normalizedPath}: p95 latency ${(ep.p95Latency / 1e3).toFixed(1)}s`);
1058
+ }
1059
+ const response = {
1060
+ summary: `Health report for ${health.length} endpoint(s).${issues.length > 0 ? ` ${issues.length} issue(s) found.` : ""}`,
1061
+ data: health.map((ep) => ({
1062
+ method: ep.method,
1063
+ path: ep.normalizedPath,
1064
+ service: ep.service,
1065
+ callCount: ep.callCount,
1066
+ successRate: `${(ep.successRate * 100).toFixed(1)}%`,
1067
+ avgLatency: `${ep.avgLatency.toFixed(0)}ms`,
1068
+ p50Latency: `${ep.p50Latency.toFixed(0)}ms`,
1069
+ p95Latency: `${ep.p95Latency.toFixed(0)}ms`,
1070
+ errorRate: `${(ep.errorRate * 100).toFixed(1)}%`,
1071
+ errorCodes: ep.errorCodes
1072
+ })),
1073
+ issues,
1074
+ metadata: {
1075
+ timeRange: { from: 0, to: Date.now() },
1076
+ eventCount: health.reduce((s, e) => s + e.callCount, 0),
1077
+ sessionId
1078
+ }
1079
+ };
1080
+ return {
1081
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1082
+ };
1083
+ }
1084
+ );
1085
+ server.tool(
1086
+ "get_api_documentation",
1087
+ "Generate API documentation from observed network traffic. Shows endpoints, auth, latency, and inferred response shapes in markdown format.",
1088
+ {
1089
+ service: z11.string().optional().describe("Generate docs for a specific service only")
1090
+ },
1091
+ async ({ service }) => {
1092
+ const docs = engine.getDocumentation({ service });
1093
+ return {
1094
+ content: [{ type: "text", text: docs }]
1095
+ };
1096
+ }
1097
+ );
1098
+ server.tool(
1099
+ "get_service_map",
1100
+ "Get a topology map of all external services the app communicates with, including detected platforms (Supabase, Vercel, Stripe, etc.), call counts, and latency.",
1101
+ {},
1102
+ async () => {
1103
+ const services = engine.getServiceMap();
1104
+ const sessions = store.getSessionInfo();
1105
+ const sessionId = sessions[0]?.sessionId ?? null;
1106
+ const response = {
1107
+ summary: `${services.length} service(s) detected from network traffic.`,
1108
+ data: services.map((s) => ({
1109
+ name: s.name,
1110
+ baseUrl: s.baseUrl,
1111
+ endpointCount: s.endpointCount,
1112
+ totalCalls: s.totalCalls,
1113
+ avgLatency: `${s.avgLatency.toFixed(0)}ms`,
1114
+ errorRate: `${(s.errorRate * 100).toFixed(1)}%`,
1115
+ auth: s.auth,
1116
+ detectedPlatform: s.detectedPlatform ?? null
1117
+ })),
1118
+ issues: [],
1119
+ metadata: {
1120
+ timeRange: { from: 0, to: Date.now() },
1121
+ eventCount: services.reduce((s, e) => s + e.totalCalls, 0),
1122
+ sessionId
1123
+ }
1124
+ };
1125
+ return {
1126
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1127
+ };
1128
+ }
1129
+ );
1130
+ server.tool(
1131
+ "get_api_changes",
1132
+ "Compare API endpoints between two sessions. Detects added/removed endpoints and response shape changes.",
1133
+ {
1134
+ session_a: z11.string().describe("First session ID"),
1135
+ session_b: z11.string().describe("Second session ID")
1136
+ },
1137
+ async ({ session_a, session_b }) => {
1138
+ const changes = engine.getApiChanges(session_a, session_b);
1139
+ const sessions = store.getSessionInfo();
1140
+ const sessionId = sessions[0]?.sessionId ?? null;
1141
+ const added = changes.filter((c) => c.changeType === "added").length;
1142
+ const removed = changes.filter((c) => c.changeType === "removed").length;
1143
+ const modified = changes.filter((c) => c.changeType === "modified").length;
1144
+ const response = {
1145
+ summary: `${changes.length} API change(s) between sessions: ${added} added, ${removed} removed, ${modified} modified.`,
1146
+ data: changes,
1147
+ issues: removed > 0 ? [`${removed} endpoint(s) no longer called \u2014 may indicate removed features or routing changes`] : [],
1148
+ metadata: {
1149
+ timeRange: { from: 0, to: Date.now() },
1150
+ eventCount: changes.length,
1151
+ sessionId
1152
+ }
1153
+ };
1154
+ return {
1155
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1156
+ };
1157
+ }
1158
+ );
1159
+ }
1160
+
1161
+ // src/tools/database.ts
1162
+ import { z as z12 } from "zod";
1163
+ import {
1164
+ aggregateQueryStats,
1165
+ detectN1Queries,
1166
+ detectSlowQueries,
1167
+ suggestIndexes
1168
+ } from "@runtimescope/collector";
1169
+ function registerDatabaseTools(server, store, connectionManager, schemaIntrospector, dataBrowser) {
1170
+ server.tool(
1171
+ "get_query_log",
1172
+ "Get captured database queries with SQL, timing, rows returned, and source ORM. Requires server-side SDK instrumentation.",
1173
+ {
1174
+ since_seconds: z12.number().optional().describe("Only return queries from the last N seconds"),
1175
+ table: z12.string().optional().describe("Filter by table name"),
1176
+ min_duration_ms: z12.number().optional().describe("Only return queries slower than N ms"),
1177
+ search: z12.string().optional().describe("Search query text")
1178
+ },
1179
+ async ({ since_seconds, table, min_duration_ms, search }) => {
1180
+ const events = store.getDatabaseEvents({
1181
+ sinceSeconds: since_seconds,
1182
+ table,
1183
+ minDurationMs: min_duration_ms,
1184
+ search
1185
+ });
1186
+ const sessions = store.getSessionInfo();
1187
+ const sessionId = sessions[0]?.sessionId ?? null;
1188
+ const totalDuration = events.reduce((s, e) => s + e.duration, 0);
1189
+ const avgDuration = events.length > 0 ? totalDuration / events.length : 0;
1190
+ const errorCount = events.filter((e) => e.error).length;
1191
+ const issues = [];
1192
+ if (errorCount > 0) issues.push(`${errorCount} query error(s)`);
1193
+ const slowCount = events.filter((e) => e.duration > 500).length;
1194
+ if (slowCount > 0) issues.push(`${slowCount} slow query/queries (>500ms)`);
1195
+ const response = {
1196
+ summary: `Found ${events.length} database query/queries${since_seconds ? ` in the last ${since_seconds}s` : ""}. Avg duration: ${avgDuration.toFixed(0)}ms.`,
1197
+ data: events.map((e) => ({
1198
+ query: e.query.slice(0, 200),
1199
+ normalizedQuery: e.normalizedQuery.slice(0, 150),
1200
+ duration: `${e.duration.toFixed(0)}ms`,
1201
+ operation: e.operation,
1202
+ tables: e.tablesAccessed,
1203
+ source: e.source,
1204
+ rowsReturned: e.rowsReturned ?? null,
1205
+ rowsAffected: e.rowsAffected ?? null,
1206
+ error: e.error ?? null,
1207
+ label: e.label ?? null,
1208
+ timestamp: new Date(e.timestamp).toISOString()
1209
+ })),
1210
+ issues,
1211
+ metadata: {
1212
+ timeRange: events.length > 0 ? { from: events[0].timestamp, to: events[events.length - 1].timestamp } : { from: 0, to: 0 },
1213
+ eventCount: events.length,
1214
+ sessionId
1215
+ }
1216
+ };
1217
+ return {
1218
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1219
+ };
1220
+ }
1221
+ );
1222
+ server.tool(
1223
+ "get_query_performance",
1224
+ "Get aggregated database query performance stats: avg/max/p95 duration, call counts, N+1 detection, and slow query analysis.",
1225
+ {
1226
+ since_seconds: z12.number().optional().describe("Analyze queries from the last N seconds")
1227
+ },
1228
+ async ({ since_seconds }) => {
1229
+ const events = store.getDatabaseEvents({ sinceSeconds: since_seconds });
1230
+ const stats = aggregateQueryStats(events);
1231
+ const n1Issues = detectN1Queries(events);
1232
+ const slowIssues = detectSlowQueries(events);
1233
+ const sessions = store.getSessionInfo();
1234
+ const sessionId = sessions[0]?.sessionId ?? null;
1235
+ const issues = [
1236
+ ...n1Issues.map((i) => i.title),
1237
+ ...slowIssues.map((i) => i.title)
1238
+ ];
1239
+ const response = {
1240
+ summary: `Analyzed ${events.length} queries across ${stats.length} unique patterns. ${issues.length} issue(s) found.`,
1241
+ data: {
1242
+ queryStats: stats.slice(0, 20).map((s) => ({
1243
+ pattern: s.normalizedQuery.slice(0, 150),
1244
+ tables: s.tables,
1245
+ operation: s.operation,
1246
+ callCount: s.callCount,
1247
+ avgDuration: `${s.avgDuration.toFixed(0)}ms`,
1248
+ maxDuration: `${s.maxDuration.toFixed(0)}ms`,
1249
+ p95Duration: `${s.p95Duration.toFixed(0)}ms`,
1250
+ totalDuration: `${s.totalDuration.toFixed(0)}ms`,
1251
+ avgRows: s.avgRowsReturned.toFixed(0)
1252
+ })),
1253
+ detectedIssues: [...n1Issues, ...slowIssues]
1254
+ },
1255
+ issues,
1256
+ metadata: {
1257
+ timeRange: { from: 0, to: Date.now() },
1258
+ eventCount: events.length,
1259
+ sessionId
1260
+ }
1261
+ };
1262
+ return {
1263
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1264
+ };
1265
+ }
1266
+ );
1267
+ server.tool(
1268
+ "get_schema_map",
1269
+ "Get the full database schema: tables, columns, types, foreign keys, and indexes. Requires a configured database connection.",
1270
+ {
1271
+ connection_id: z12.string().optional().describe("Connection ID (defaults to first available)"),
1272
+ table: z12.string().optional().describe("Introspect a specific table only")
1273
+ },
1274
+ async ({ connection_id, table }) => {
1275
+ const connections = connectionManager.listConnections();
1276
+ if (connections.length === 0) {
1277
+ return {
1278
+ content: [{ type: "text", text: JSON.stringify({
1279
+ summary: "No database connections configured.",
1280
+ data: null,
1281
+ issues: ["Configure a database connection in your project's infrastructure config."],
1282
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1283
+ }, null, 2) }]
1284
+ };
1285
+ }
1286
+ const connId = connection_id ?? connections[0].id;
1287
+ const conn = connectionManager.getConnection(connId);
1288
+ if (!conn) {
1289
+ return {
1290
+ content: [{ type: "text", text: JSON.stringify({
1291
+ summary: `Connection "${connId}" not found.`,
1292
+ data: null,
1293
+ issues: [`Available connections: ${connections.map((c) => c.id).join(", ")}`],
1294
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1295
+ }, null, 2) }]
1296
+ };
1297
+ }
1298
+ let schema;
1299
+ try {
1300
+ schema = await schemaIntrospector.introspect(conn, table);
1301
+ } catch (err) {
1302
+ return {
1303
+ content: [{ type: "text", text: JSON.stringify({
1304
+ summary: `Schema introspection failed: ${err.message}`,
1305
+ data: null,
1306
+ issues: [err.message],
1307
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1308
+ }, null, 2) }]
1309
+ };
1310
+ }
1311
+ const response = {
1312
+ summary: `Schema for ${schema.connectionId}: ${schema.tables.length} table(s).`,
1313
+ data: schema.tables.map((t) => ({
1314
+ name: t.name,
1315
+ rowCount: t.rowCount ?? null,
1316
+ columns: t.columns.map((c) => ({
1317
+ name: c.name,
1318
+ type: c.type,
1319
+ nullable: c.nullable,
1320
+ isPrimaryKey: c.isPrimaryKey,
1321
+ default: c.defaultValue ?? null
1322
+ })),
1323
+ foreignKeys: t.foreignKeys,
1324
+ indexes: t.indexes
1325
+ })),
1326
+ issues: [],
1327
+ metadata: {
1328
+ timeRange: { from: schema.fetchedAt, to: schema.fetchedAt },
1329
+ eventCount: schema.tables.length,
1330
+ sessionId: null
1331
+ }
1332
+ };
1333
+ return {
1334
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1335
+ };
1336
+ }
1337
+ );
1338
+ server.tool(
1339
+ "get_table_data",
1340
+ "Read rows from a database table with pagination. Requires a configured database connection.",
1341
+ {
1342
+ table: z12.string().describe("Table name to read"),
1343
+ connection_id: z12.string().optional().describe("Connection ID"),
1344
+ limit: z12.number().optional().describe("Max rows (default 50, max 1000)"),
1345
+ offset: z12.number().optional().describe("Pagination offset"),
1346
+ where: z12.string().optional().describe("SQL WHERE clause (without WHERE keyword)"),
1347
+ order_by: z12.string().optional().describe("SQL ORDER BY clause (without ORDER BY keyword)")
1348
+ },
1349
+ async ({ table, connection_id, limit, offset, where, order_by }) => {
1350
+ const connections = connectionManager.listConnections();
1351
+ const connId = connection_id ?? connections[0]?.id;
1352
+ if (!connId) {
1353
+ return {
1354
+ content: [{ type: "text", text: JSON.stringify({
1355
+ summary: "No database connections configured.",
1356
+ data: null,
1357
+ issues: ["Configure a database connection."],
1358
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1359
+ }, null, 2) }]
1360
+ };
1361
+ }
1362
+ const conn = connectionManager.getConnection(connId);
1363
+ if (!conn) {
1364
+ return {
1365
+ content: [{ type: "text", text: `Connection "${connId}" not found.` }]
1366
+ };
1367
+ }
1368
+ let result;
1369
+ try {
1370
+ result = await dataBrowser.read(conn, { table, limit, offset, where, orderBy: order_by });
1371
+ } catch (err) {
1372
+ return {
1373
+ content: [{ type: "text", text: JSON.stringify({
1374
+ summary: `Read failed: ${err.message}`,
1375
+ data: null,
1376
+ issues: [err.message],
1377
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1378
+ }, null, 2) }]
1379
+ };
1380
+ }
1381
+ const response = {
1382
+ summary: `${result.rows.length} row(s) from "${table}" (${result.total} total).`,
1383
+ data: { rows: result.rows, total: result.total, limit: result.limit, offset: result.offset },
1384
+ issues: [],
1385
+ metadata: {
1386
+ timeRange: { from: 0, to: Date.now() },
1387
+ eventCount: result.rows.length,
1388
+ sessionId: null
1389
+ }
1390
+ };
1391
+ return {
1392
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1393
+ };
1394
+ }
1395
+ );
1396
+ server.tool(
1397
+ "modify_table_data",
1398
+ "Insert, update, or delete rows in a LOCAL DEV database. Safety guarded: localhost only, WHERE required for update/delete, max 100 affected rows, wrapped in transaction.",
1399
+ {
1400
+ table: z12.string().describe("Table name"),
1401
+ operation: z12.enum(["insert", "update", "delete"]).describe("Operation type"),
1402
+ connection_id: z12.string().optional().describe("Connection ID"),
1403
+ data: z12.record(z12.unknown()).optional().describe("Row data (for insert/update)"),
1404
+ where: z12.string().optional().describe("WHERE clause (required for update/delete)")
1405
+ },
1406
+ async ({ table, operation, connection_id, data, where }) => {
1407
+ const connections = connectionManager.listConnections();
1408
+ const connId = connection_id ?? connections[0]?.id;
1409
+ if (!connId) {
1410
+ return {
1411
+ content: [{ type: "text", text: "No database connections configured." }]
1412
+ };
1413
+ }
1414
+ const conn = connectionManager.getConnection(connId);
1415
+ if (!conn) {
1416
+ return {
1417
+ content: [{ type: "text", text: `Connection "${connId}" not found.` }]
1418
+ };
1419
+ }
1420
+ if ((operation === "update" || operation === "delete") && !where) {
1421
+ return {
1422
+ content: [{ type: "text", text: JSON.stringify({
1423
+ summary: `WHERE clause required for ${operation} operations.`,
1424
+ data: null,
1425
+ issues: [`${operation} without WHERE clause is not allowed`],
1426
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1427
+ }, null, 2) }]
1428
+ };
1429
+ }
1430
+ let result;
1431
+ try {
1432
+ result = await dataBrowser.write(conn, { table, operation, data, where });
1433
+ } catch (err) {
1434
+ return {
1435
+ content: [{ type: "text", text: JSON.stringify({
1436
+ summary: `Write failed: ${err.message}`,
1437
+ data: null,
1438
+ issues: [err.message],
1439
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1440
+ }, null, 2) }]
1441
+ };
1442
+ }
1443
+ const response = {
1444
+ summary: result.success ? `${operation} on "${table}": ${result.affectedRows} row(s) affected.` : `${operation} on "${table}" failed: ${result.error}`,
1445
+ data: result,
1446
+ issues: result.error ? [result.error] : [],
1447
+ metadata: {
1448
+ timeRange: { from: Date.now(), to: Date.now() },
1449
+ eventCount: result.affectedRows,
1450
+ sessionId: null
1451
+ }
1452
+ };
1453
+ return {
1454
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1455
+ };
1456
+ }
1457
+ );
1458
+ server.tool(
1459
+ "get_database_connections",
1460
+ "List all configured database connections with their health status.",
1461
+ {},
1462
+ async () => {
1463
+ const connections = connectionManager.listConnections();
1464
+ const response = {
1465
+ summary: `${connections.length} database connection(s) configured.`,
1466
+ data: connections,
1467
+ issues: connections.filter((c) => !c.isHealthy).map((c) => `Connection "${c.id}" is unhealthy`),
1468
+ metadata: {
1469
+ timeRange: { from: 0, to: Date.now() },
1470
+ eventCount: connections.length,
1471
+ sessionId: null
1472
+ }
1473
+ };
1474
+ return {
1475
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1476
+ };
1477
+ }
1478
+ );
1479
+ server.tool(
1480
+ "suggest_indexes",
1481
+ "Analyze captured database queries and suggest missing indexes based on WHERE/ORDER BY columns and query performance.",
1482
+ {
1483
+ since_seconds: z12.number().optional().describe("Analyze queries from the last N seconds")
1484
+ },
1485
+ async ({ since_seconds }) => {
1486
+ const events = store.getDatabaseEvents({ sinceSeconds: since_seconds });
1487
+ const suggestions = suggestIndexes(events);
1488
+ const sessions = store.getSessionInfo();
1489
+ const sessionId = sessions[0]?.sessionId ?? null;
1490
+ const response = {
1491
+ summary: `${suggestions.length} index suggestion(s) based on ${events.length} captured queries.`,
1492
+ data: suggestions.map((s) => ({
1493
+ table: s.table,
1494
+ columns: s.columns,
1495
+ reason: s.reason,
1496
+ estimatedImpact: s.estimatedImpact,
1497
+ queryPattern: s.queryPattern,
1498
+ suggestedSQL: `CREATE INDEX idx_${s.table}_${s.columns.join("_")} ON ${s.table}(${s.columns.join(", ")});`
1499
+ })),
1500
+ issues: suggestions.filter((s) => s.estimatedImpact === "high").map((s) => `High-impact index missing on ${s.table}(${s.columns.join(", ")})`),
1501
+ metadata: {
1502
+ timeRange: { from: 0, to: Date.now() },
1503
+ eventCount: events.length,
1504
+ sessionId
1505
+ }
1506
+ };
1507
+ return {
1508
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1509
+ };
1510
+ }
1511
+ );
1512
+ }
1513
+
1514
+ // src/tools/process-monitor.ts
1515
+ import { z as z13 } from "zod";
1516
+ import { execSync, spawn } from "child_process";
1517
+ import { existsSync, statSync, rmSync } from "fs";
1518
+ import { join } from "path";
1519
+ function registerProcessMonitorTools(server, processMonitor) {
1520
+ server.tool(
1521
+ "get_dev_processes",
1522
+ "List all running dev processes (Next.js, Vite, Prisma, Docker, databases, etc.) with PID, port, memory, and CPU usage.",
1523
+ {
1524
+ type: z13.string().optional().describe("Filter by process type (next, vite, docker, postgres, etc.)"),
1525
+ project: z13.string().optional().describe("Filter by project name")
1526
+ },
1527
+ async ({ type, project }) => {
1528
+ processMonitor.scan();
1529
+ const processes = processMonitor.getProcesses({
1530
+ type,
1531
+ project
1532
+ });
1533
+ const issues = processMonitor.detectIssues();
1534
+ const response = {
1535
+ summary: `${processes.length} dev process(es) running.${issues.length > 0 ? ` ${issues.length} issue(s) detected.` : ""}`,
1536
+ data: processes.map((p) => ({
1537
+ pid: p.pid,
1538
+ type: p.type,
1539
+ command: p.command,
1540
+ cpuPercent: `${p.cpuPercent}%`,
1541
+ memoryMB: `${p.memoryMB.toFixed(0)}MB`,
1542
+ ports: p.ports,
1543
+ cwd: p.cwd ?? null,
1544
+ project: p.project ?? null,
1545
+ isOrphaned: p.isOrphaned
1546
+ })),
1547
+ issues: issues.map((i) => i.title),
1548
+ metadata: {
1549
+ timeRange: { from: Date.now(), to: Date.now() },
1550
+ eventCount: processes.length,
1551
+ sessionId: null
1552
+ }
1553
+ };
1554
+ return {
1555
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1556
+ };
1557
+ }
1558
+ );
1559
+ server.tool(
1560
+ "kill_process",
1561
+ "Terminate a dev process by PID. Default signal is SIGTERM; use SIGKILL for force kill.",
1562
+ {
1563
+ pid: z13.number().describe("Process ID to kill"),
1564
+ signal: z13.enum(["SIGTERM", "SIGKILL"]).optional().describe("Signal to send (default: SIGTERM)")
1565
+ },
1566
+ async ({ pid, signal }) => {
1567
+ if (pid < 2 || pid === process.pid) {
1568
+ return {
1569
+ content: [{ type: "text", text: JSON.stringify({
1570
+ summary: `Refusing to kill PID ${pid}: ${pid < 2 ? "system process" : "current process"}.`,
1571
+ data: { success: false, pid },
1572
+ issues: [`Cannot kill PID ${pid}`],
1573
+ metadata: { timeRange: { from: Date.now(), to: Date.now() }, eventCount: 0, sessionId: null }
1574
+ }, null, 2) }]
1575
+ };
1576
+ }
1577
+ const result = processMonitor.killProcess(pid, signal ?? "SIGTERM");
1578
+ const response = {
1579
+ summary: result.success ? `Process ${pid} terminated with ${signal ?? "SIGTERM"}.` : `Failed to kill process ${pid}: ${result.error}`,
1580
+ data: result,
1581
+ issues: result.error ? [result.error] : [],
1582
+ metadata: {
1583
+ timeRange: { from: Date.now(), to: Date.now() },
1584
+ eventCount: 1,
1585
+ sessionId: null
1586
+ }
1587
+ };
1588
+ return {
1589
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1590
+ };
1591
+ }
1592
+ );
1593
+ server.tool(
1594
+ "get_port_usage",
1595
+ "Show which dev processes are bound to which ports. Useful for debugging port conflicts.",
1596
+ {
1597
+ port: z13.number().optional().describe("Filter by specific port number")
1598
+ },
1599
+ async ({ port }) => {
1600
+ processMonitor.scan();
1601
+ const ports = processMonitor.getPortUsage(port);
1602
+ const response = {
1603
+ summary: `${ports.length} port binding(s) found.`,
1604
+ data: ports.map((p) => ({
1605
+ port: p.port,
1606
+ pid: p.pid,
1607
+ process: p.process,
1608
+ type: p.type,
1609
+ project: p.project ?? null
1610
+ })),
1611
+ issues: [],
1612
+ metadata: {
1613
+ timeRange: { from: Date.now(), to: Date.now() },
1614
+ eventCount: ports.length,
1615
+ sessionId: null
1616
+ }
1617
+ };
1618
+ return {
1619
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1620
+ };
1621
+ }
1622
+ );
1623
+ server.tool(
1624
+ "purge_caches",
1625
+ "Delete common build/dev cache directories (.next/cache, node_modules/.cache, .vite, .turbo, .swc, .parcel-cache, etc.) for a project directory. Reports size freed per cache.",
1626
+ {
1627
+ directory: z13.string().describe("Absolute path to the project directory"),
1628
+ dryRun: z13.boolean().optional().describe("If true, report what would be deleted without actually deleting (default: false)")
1629
+ },
1630
+ async ({ directory, dryRun }) => {
1631
+ const CACHE_TARGETS = [
1632
+ ".next/cache",
1633
+ "node_modules/.cache",
1634
+ "node_modules/.vite",
1635
+ ".turbo",
1636
+ ".cache",
1637
+ ".swc",
1638
+ ".parcel-cache",
1639
+ ".nuxt",
1640
+ "tsconfig.tsbuildinfo"
1641
+ ];
1642
+ const purged = [];
1643
+ let totalFreed = 0;
1644
+ for (const target of CACHE_TARGETS) {
1645
+ const fullPath = join(directory, target);
1646
+ if (!existsSync(fullPath)) continue;
1647
+ const sizeMB = getDirSizeMB(fullPath);
1648
+ const deleted = !dryRun;
1649
+ if (!dryRun) {
1650
+ try {
1651
+ rmSync(fullPath, { recursive: true, force: true });
1652
+ } catch {
1653
+ purged.push({ path: target, sizeMB, deleted: false });
1654
+ continue;
1655
+ }
1656
+ }
1657
+ totalFreed += sizeMB;
1658
+ purged.push({ path: target, sizeMB, deleted });
1659
+ }
1660
+ const mode = dryRun ? "Dry run" : "Purged";
1661
+ const response = {
1662
+ summary: purged.length > 0 ? `${mode}: ${purged.length} cache(s), ${totalFreed.toFixed(1)}MB ${dryRun ? "would be freed" : "freed"}.` : "No caches found to purge.",
1663
+ data: {
1664
+ directory,
1665
+ dryRun: dryRun ?? false,
1666
+ totalFreedMB: parseFloat(totalFreed.toFixed(1)),
1667
+ caches: purged
1668
+ },
1669
+ issues: purged.filter((p) => !p.deleted && !dryRun).map((p) => `Failed to delete ${p.path}`),
1670
+ metadata: {
1671
+ timeRange: { from: Date.now(), to: Date.now() },
1672
+ eventCount: purged.length,
1673
+ sessionId: null
1674
+ }
1675
+ };
1676
+ return {
1677
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1678
+ };
1679
+ }
1680
+ );
1681
+ server.tool(
1682
+ "restart_dev_server",
1683
+ "Kill a dev server process, purge build caches in its working directory, and restart it with the same or a custom command. Combines kill_process + purge_caches + spawn into one operation.",
1684
+ {
1685
+ pid: z13.number().describe("PID of the dev server process to restart"),
1686
+ command: z13.string().optional().describe('Custom start command (e.g. "npm run dev"). If omitted, infers from process type.'),
1687
+ skipCachePurge: z13.boolean().optional().describe("If true, skip cache purging (default: false)"),
1688
+ signal: z13.enum(["SIGTERM", "SIGKILL"]).optional().describe("Kill signal (default: SIGTERM)")
1689
+ },
1690
+ async ({ pid, command, skipCachePurge, signal }) => {
1691
+ if (pid < 2 || pid === process.pid) {
1692
+ return {
1693
+ content: [{ type: "text", text: JSON.stringify({
1694
+ summary: `Refusing to restart PID ${pid}: ${pid < 2 ? "system process" : "current process"}.`,
1695
+ data: { success: false, pid },
1696
+ issues: [`Cannot kill PID ${pid}`],
1697
+ metadata: { timeRange: { from: Date.now(), to: Date.now() }, eventCount: 0, sessionId: null }
1698
+ }, null, 2) }]
1699
+ };
1700
+ }
1701
+ processMonitor.scan();
1702
+ const processes = processMonitor.getProcesses();
1703
+ const proc = processes.find((p) => p.pid === pid);
1704
+ if (!proc) {
1705
+ return {
1706
+ content: [{
1707
+ type: "text",
1708
+ text: JSON.stringify({
1709
+ summary: `Process ${pid} not found. It may have already exited.`,
1710
+ data: { pid, found: false },
1711
+ issues: [`Process ${pid} not found`],
1712
+ metadata: { timeRange: { from: Date.now(), to: Date.now() }, eventCount: 0, sessionId: null }
1713
+ }, null, 2)
1714
+ }]
1715
+ };
1716
+ }
1717
+ const cwd = proc.cwd;
1718
+ const startCommand = command ?? inferStartCommand(proc.type, proc.command);
1719
+ const killResult = processMonitor.killProcess(pid, signal ?? "SIGTERM");
1720
+ if (!killResult.success) {
1721
+ return {
1722
+ content: [{
1723
+ type: "text",
1724
+ text: JSON.stringify({
1725
+ summary: `Failed to kill process ${pid}: ${killResult.error}`,
1726
+ data: { pid, killed: false, error: killResult.error },
1727
+ issues: [killResult.error ?? "Unknown error"],
1728
+ metadata: { timeRange: { from: Date.now(), to: Date.now() }, eventCount: 0, sessionId: null }
1729
+ }, null, 2)
1730
+ }]
1731
+ };
1732
+ }
1733
+ await new Promise((r) => setTimeout(r, 500));
1734
+ let cachesFreedMB = 0;
1735
+ let cachesPurged = 0;
1736
+ if (!skipCachePurge && cwd) {
1737
+ const CACHE_TARGETS = [
1738
+ ".next/cache",
1739
+ "node_modules/.cache",
1740
+ "node_modules/.vite",
1741
+ ".turbo",
1742
+ ".cache",
1743
+ ".swc",
1744
+ ".parcel-cache",
1745
+ ".nuxt",
1746
+ "tsconfig.tsbuildinfo"
1747
+ ];
1748
+ for (const target of CACHE_TARGETS) {
1749
+ const fullPath = join(cwd, target);
1750
+ if (!existsSync(fullPath)) continue;
1751
+ const sizeMB = getDirSizeMB(fullPath);
1752
+ try {
1753
+ rmSync(fullPath, { recursive: true, force: true });
1754
+ cachesFreedMB += sizeMB;
1755
+ cachesPurged++;
1756
+ } catch {
1757
+ }
1758
+ }
1759
+ }
1760
+ let restarted = false;
1761
+ let newPid = null;
1762
+ let restartError;
1763
+ if (startCommand && cwd) {
1764
+ try {
1765
+ const child = spawn(startCommand, {
1766
+ cwd,
1767
+ shell: true,
1768
+ detached: true,
1769
+ stdio: "ignore"
1770
+ });
1771
+ child.unref();
1772
+ newPid = child.pid ?? null;
1773
+ restarted = true;
1774
+ } catch (err) {
1775
+ restartError = err.message;
1776
+ }
1777
+ } else if (!startCommand) {
1778
+ restartError = 'Could not infer start command. Provide one via the "command" parameter.';
1779
+ } else if (!cwd) {
1780
+ restartError = "Could not determine working directory for the process. Provide a command and working directory manually.";
1781
+ }
1782
+ const response = {
1783
+ summary: [
1784
+ `Killed ${proc.type} process ${pid}.`,
1785
+ cachesPurged > 0 ? `Purged ${cachesPurged} cache(s) (${cachesFreedMB.toFixed(1)}MB).` : null,
1786
+ restarted ? `Restarted with PID ${newPid} using: ${startCommand}` : null,
1787
+ restartError ? `Restart failed: ${restartError}` : null
1788
+ ].filter(Boolean).join(" "),
1789
+ data: {
1790
+ killed: { pid, type: proc.type, signal: signal ?? "SIGTERM" },
1791
+ cachesPurged: { count: cachesPurged, freedMB: parseFloat(cachesFreedMB.toFixed(1)) },
1792
+ restarted: { success: restarted, newPid, command: startCommand, cwd }
1793
+ },
1794
+ issues: restartError ? [restartError] : [],
1795
+ metadata: {
1796
+ timeRange: { from: Date.now(), to: Date.now() },
1797
+ eventCount: 1,
1798
+ sessionId: null
1799
+ }
1800
+ };
1801
+ return {
1802
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1803
+ };
1804
+ }
1805
+ );
1806
+ }
1807
+ function getDirSizeMB(path) {
1808
+ try {
1809
+ const stat = statSync(path);
1810
+ if (stat.isFile()) return stat.size / (1024 * 1024);
1811
+ const output = execSync(`du -sk "${path}" 2>/dev/null`, { encoding: "utf-8", timeout: 5e3 });
1812
+ const kb = parseInt(output.trim().split(" ")[0], 10);
1813
+ return isNaN(kb) ? 0 : kb / 1024;
1814
+ } catch {
1815
+ return 0;
1816
+ }
1817
+ }
1818
+ function inferStartCommand(type, rawCommand) {
1819
+ const defaults = {
1820
+ next: "npx next dev",
1821
+ vite: "npx vite",
1822
+ webpack: "npx webpack serve",
1823
+ wrangler: "npx wrangler dev",
1824
+ prisma: "npx prisma studio",
1825
+ bun: "bun run dev",
1826
+ deno: "deno task dev"
1827
+ };
1828
+ if (defaults[type]) return defaults[type];
1829
+ if (rawCommand.includes("ts-node") || rawCommand.includes("tsx")) {
1830
+ const match = rawCommand.match(/(ts-node|tsx)\s+(.+)/);
1831
+ if (match) return `npx ${match[1]} ${match[2]}`;
1832
+ }
1833
+ if (type === "node") return "npm run dev";
1834
+ return null;
1835
+ }
1836
+
1837
+ // src/tools/infra-connector.ts
1838
+ import { z as z14 } from "zod";
1839
+ function registerInfraTools(server, infraConnector) {
1840
+ server.tool(
1841
+ "get_deploy_logs",
1842
+ "Get deployment history from connected platforms (Vercel, Cloudflare, Railway). Shows build status, branch, commit, and timing.",
1843
+ {
1844
+ project: z14.string().optional().describe("Project name"),
1845
+ platform: z14.string().optional().describe("Filter by platform (vercel, cloudflare, railway)"),
1846
+ deploy_id: z14.string().optional().describe("Get details for a specific deployment")
1847
+ },
1848
+ async ({ project, platform, deploy_id }) => {
1849
+ const logs = await infraConnector.getDeployLogs(project ?? "default", platform, deploy_id);
1850
+ const response = {
1851
+ summary: `${logs.length} deployment(s) found.`,
1852
+ data: logs.map((l) => ({
1853
+ id: l.id,
1854
+ platform: l.platform,
1855
+ status: l.status,
1856
+ url: l.url ?? null,
1857
+ branch: l.branch ?? null,
1858
+ commit: l.commit?.slice(0, 8) ?? null,
1859
+ createdAt: new Date(l.createdAt).toISOString(),
1860
+ readyAt: l.readyAt ? new Date(l.readyAt).toISOString() : null,
1861
+ error: l.errorMessage ?? null
1862
+ })),
1863
+ issues: logs.filter((l) => l.status === "error").map((l) => `Deploy ${l.id.slice(0, 8)} failed on ${l.platform}`),
1864
+ metadata: {
1865
+ timeRange: logs.length > 0 ? { from: Math.min(...logs.map((l) => l.createdAt)), to: Math.max(...logs.map((l) => l.createdAt)) } : { from: 0, to: 0 },
1866
+ eventCount: logs.length,
1867
+ sessionId: null
1868
+ }
1869
+ };
1870
+ return {
1871
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1872
+ };
1873
+ }
1874
+ );
1875
+ server.tool(
1876
+ "get_runtime_logs",
1877
+ "Get runtime error/info logs from connected deployment platforms.",
1878
+ {
1879
+ project: z14.string().optional().describe("Project name"),
1880
+ platform: z14.string().optional().describe("Filter by platform"),
1881
+ level: z14.string().optional().describe("Filter by log level (info, warn, error)"),
1882
+ since_seconds: z14.number().optional().describe("Only return logs from the last N seconds")
1883
+ },
1884
+ async ({ project, platform, level, since_seconds }) => {
1885
+ const since = since_seconds ? Date.now() - since_seconds * 1e3 : void 0;
1886
+ const logs = await infraConnector.getRuntimeLogs(project ?? "default", { platform, since, level });
1887
+ const response = {
1888
+ summary: `${logs.length} runtime log(s) found.`,
1889
+ data: logs.map((l) => ({
1890
+ timestamp: new Date(l.timestamp).toISOString(),
1891
+ level: l.level,
1892
+ message: l.message,
1893
+ source: l.source ?? null,
1894
+ platform: l.platform
1895
+ })),
1896
+ issues: logs.filter((l) => l.level === "error").length > 0 ? [`${logs.filter((l) => l.level === "error").length} error(s) in runtime logs`] : [],
1897
+ metadata: {
1898
+ timeRange: logs.length > 0 ? { from: logs[logs.length - 1].timestamp, to: logs[0].timestamp } : { from: 0, to: 0 },
1899
+ eventCount: logs.length,
1900
+ sessionId: null
1901
+ }
1902
+ };
1903
+ return {
1904
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1905
+ };
1906
+ }
1907
+ );
1908
+ server.tool(
1909
+ "get_build_status",
1910
+ "Get the current deployment status for each connected platform.",
1911
+ {
1912
+ project: z14.string().optional().describe("Project name")
1913
+ },
1914
+ async ({ project }) => {
1915
+ const statuses = await infraConnector.getBuildStatus(project ?? "default");
1916
+ const response = {
1917
+ summary: `${statuses.length} platform(s) reporting build status.`,
1918
+ data: statuses.map((s) => ({
1919
+ platform: s.platform,
1920
+ project: s.project,
1921
+ status: s.status,
1922
+ url: s.url ?? null,
1923
+ lastDeployed: new Date(s.lastDeployed).toISOString(),
1924
+ deployId: s.latestDeployId
1925
+ })),
1926
+ issues: statuses.filter((s) => s.status === "error").map((s) => `${s.platform}: latest deploy failed`),
1927
+ metadata: {
1928
+ timeRange: { from: 0, to: Date.now() },
1929
+ eventCount: statuses.length,
1930
+ sessionId: null
1931
+ }
1932
+ };
1933
+ return {
1934
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1935
+ };
1936
+ }
1937
+ );
1938
+ server.tool(
1939
+ "get_infra_overview",
1940
+ "Overview of which platforms a project uses, combining explicit configuration with auto-detection from network traffic.",
1941
+ {
1942
+ project: z14.string().optional().describe("Project name")
1943
+ },
1944
+ async ({ project }) => {
1945
+ const overview = infraConnector.getInfraOverview(project);
1946
+ const response = {
1947
+ summary: overview.length > 0 ? `Infrastructure overview: ${overview[0].platforms.length} configured platform(s), ${overview[0].detectedFromTraffic.length} detected from traffic.` : "No infrastructure information available.",
1948
+ data: overview,
1949
+ issues: [],
1950
+ metadata: {
1951
+ timeRange: { from: 0, to: Date.now() },
1952
+ eventCount: overview.length,
1953
+ sessionId: null
1954
+ }
1955
+ };
1956
+ return {
1957
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
1958
+ };
1959
+ }
1960
+ );
1961
+ }
1962
+
1963
+ // src/tools/session-diff.ts
1964
+ import { z as z15 } from "zod";
1965
+ import { compareSessions } from "@runtimescope/collector";
1966
+ function registerSessionDiffTools(server, sessionManager) {
1967
+ server.tool(
1968
+ "compare_sessions",
1969
+ "Compare two sessions: render counts, API latency, errors, Web Vitals, and query performance. Shows regressions and improvements.",
1970
+ {
1971
+ session_a: z15.string().describe("First session ID (baseline)"),
1972
+ session_b: z15.string().describe("Second session ID (comparison)"),
1973
+ project: z15.string().optional().describe("Project name")
1974
+ },
1975
+ async ({ session_a, session_b, project }) => {
1976
+ const projectName = project ?? "default";
1977
+ const history = sessionManager.getSessionHistory(projectName, 100);
1978
+ const snapshotA = history.find((s) => s.sessionId === session_a);
1979
+ const snapshotB = history.find((s) => s.sessionId === session_b);
1980
+ if (!snapshotA || !snapshotB) {
1981
+ return {
1982
+ content: [{ type: "text", text: JSON.stringify({
1983
+ summary: "Could not find one or both sessions in history.",
1984
+ data: null,
1985
+ issues: [
1986
+ !snapshotA ? `Session ${session_a} not found` : null,
1987
+ !snapshotB ? `Session ${session_b} not found` : null
1988
+ ].filter(Boolean),
1989
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1990
+ }, null, 2) }]
1991
+ };
1992
+ }
1993
+ const diff = compareSessions(snapshotA.metrics, snapshotB.metrics);
1994
+ const regressions = [
1995
+ ...diff.endpointDeltas.filter((d) => d.classification === "regression"),
1996
+ ...diff.componentDeltas.filter((d) => d.classification === "regression"),
1997
+ ...diff.webVitalDeltas.filter((d) => d.classification === "regression"),
1998
+ ...diff.queryDeltas.filter((d) => d.classification === "regression")
1999
+ ];
2000
+ const improvements = [
2001
+ ...diff.endpointDeltas.filter((d) => d.classification === "improvement"),
2002
+ ...diff.componentDeltas.filter((d) => d.classification === "improvement"),
2003
+ ...diff.webVitalDeltas.filter((d) => d.classification === "improvement"),
2004
+ ...diff.queryDeltas.filter((d) => d.classification === "improvement")
2005
+ ];
2006
+ const response = {
2007
+ summary: `Session comparison: ${regressions.length} regression(s), ${improvements.length} improvement(s). Error delta: ${diff.overallDelta.errorCountDelta >= 0 ? "+" : ""}${diff.overallDelta.errorCountDelta}.`,
2008
+ data: {
2009
+ endpointDeltas: diff.endpointDeltas.map((d) => ({
2010
+ ...d,
2011
+ before: `${d.before.toFixed(0)}ms`,
2012
+ after: `${d.after.toFixed(0)}ms`,
2013
+ percentChange: `${(d.percentChange * 100).toFixed(1)}%`
2014
+ })),
2015
+ componentDeltas: diff.componentDeltas.map((d) => ({
2016
+ ...d,
2017
+ percentChange: `${(d.percentChange * 100).toFixed(1)}%`
2018
+ })),
2019
+ webVitalDeltas: diff.webVitalDeltas.map((d) => ({
2020
+ ...d,
2021
+ percentChange: `${(d.percentChange * 100).toFixed(1)}%`
2022
+ })),
2023
+ queryDeltas: diff.queryDeltas.map((d) => ({
2024
+ ...d,
2025
+ before: `${d.before.toFixed(0)}ms`,
2026
+ after: `${d.after.toFixed(0)}ms`,
2027
+ percentChange: `${(d.percentChange * 100).toFixed(1)}%`
2028
+ })),
2029
+ overallDelta: diff.overallDelta
2030
+ },
2031
+ issues: regressions.map((r) => `Regression: ${r.key} (${(r.percentChange * 100).toFixed(1)}% worse)`),
2032
+ metadata: {
2033
+ timeRange: { from: snapshotA.createdAt, to: snapshotB.createdAt },
2034
+ eventCount: 2,
2035
+ sessionId: null
2036
+ }
2037
+ };
2038
+ return {
2039
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2040
+ };
2041
+ }
2042
+ );
2043
+ server.tool(
2044
+ "get_session_history",
2045
+ "List past sessions with build metadata, event counts, and timestamps. Requires SQLite persistence.",
2046
+ {
2047
+ project: z15.string().optional().describe("Project name"),
2048
+ limit: z15.number().optional().describe("Max sessions to return (default 20)")
2049
+ },
2050
+ async ({ project, limit }) => {
2051
+ const projectName = project ?? "default";
2052
+ const history = sessionManager.getSessionHistory(projectName, limit ?? 20);
2053
+ const response = {
2054
+ summary: `${history.length} session(s) in history for project "${projectName}".`,
2055
+ data: history.map((s) => ({
2056
+ sessionId: s.sessionId,
2057
+ project: s.project,
2058
+ createdAt: new Date(s.createdAt).toISOString(),
2059
+ totalEvents: s.metrics.totalEvents,
2060
+ errorCount: s.metrics.errorCount,
2061
+ endpointCount: Object.keys(s.metrics.endpoints).length,
2062
+ componentCount: Object.keys(s.metrics.components).length,
2063
+ buildMeta: s.buildMeta ?? null
2064
+ })),
2065
+ issues: [],
2066
+ metadata: {
2067
+ timeRange: history.length > 0 ? { from: history[history.length - 1].createdAt, to: history[0].createdAt } : { from: 0, to: 0 },
2068
+ eventCount: history.length,
2069
+ sessionId: null
2070
+ }
2071
+ };
2072
+ return {
2073
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2074
+ };
2075
+ }
2076
+ );
2077
+ }
2078
+
2079
+ // src/tools/recon-metadata.ts
2080
+ import { z as z16 } from "zod";
2081
+ function registerReconMetadataTools(server, store, collector) {
2082
+ server.tool(
2083
+ "get_page_metadata",
2084
+ "Get page metadata and tech stack detection for the current page. Returns URL, viewport, meta tags, detected framework/UI library/build tool/hosting, external stylesheets and scripts. Requires the RuntimeScope extension to be connected.",
2085
+ {
2086
+ url: z16.string().optional().describe("Filter by URL substring"),
2087
+ force_refresh: z16.boolean().optional().default(false).describe("Send a recon_scan command to the extension to capture fresh data")
2088
+ },
2089
+ async ({ url, force_refresh }) => {
2090
+ if (force_refresh) {
2091
+ const sessions2 = store.getSessionInfo();
2092
+ const activeSession = sessions2.find((s) => s.isConnected);
2093
+ if (activeSession) {
2094
+ try {
2095
+ await collector.sendCommand(activeSession.sessionId, {
2096
+ command: "recon_scan",
2097
+ requestId: crypto.randomUUID(),
2098
+ params: { categories: ["recon_metadata"] }
2099
+ });
2100
+ } catch {
2101
+ }
2102
+ }
2103
+ }
2104
+ const event = store.getReconMetadata({ url });
2105
+ const sessions = store.getSessionInfo();
2106
+ const sessionId = sessions[0]?.sessionId ?? null;
2107
+ if (!event) {
2108
+ return {
2109
+ content: [{
2110
+ type: "text",
2111
+ text: JSON.stringify({
2112
+ summary: "No page metadata captured yet. Ensure the RuntimeScope extension is connected and has scanned a page.",
2113
+ data: null,
2114
+ issues: ["No recon_metadata events found in the event store"],
2115
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2116
+ }, null, 2)
2117
+ }]
2118
+ };
2119
+ }
2120
+ const ts = event.techStack;
2121
+ const stackParts = [];
2122
+ if (ts.framework.name !== "unknown") stackParts.push(`${ts.framework.name}${ts.framework.version ? " " + ts.framework.version : ""}`);
2123
+ if (ts.metaFramework?.name && ts.metaFramework.name !== "unknown") stackParts.push(ts.metaFramework.name);
2124
+ if (ts.uiLibrary?.name && ts.uiLibrary.name !== "unknown") stackParts.push(ts.uiLibrary.name);
2125
+ if (ts.hosting?.name && ts.hosting.name !== "unknown") stackParts.push(`on ${ts.hosting.name}`);
2126
+ const issues = [];
2127
+ if (!event.metaTags["viewport"]) {
2128
+ issues.push("No viewport meta tag detected");
2129
+ }
2130
+ if (ts.framework.confidence === "low") {
2131
+ issues.push(`Framework detection confidence is low: ${ts.framework.name}`);
2132
+ }
2133
+ const response = {
2134
+ summary: `Page: ${event.title || event.url}. Tech stack: ${stackParts.join(" + ") || "unknown"}. ${event.externalStylesheets.length} stylesheets, ${event.externalScripts.length} scripts.`,
2135
+ data: {
2136
+ url: event.url,
2137
+ title: event.title,
2138
+ viewport: event.viewport,
2139
+ documentLang: event.documentLang,
2140
+ metaTags: event.metaTags,
2141
+ techStack: {
2142
+ framework: ts.framework,
2143
+ metaFramework: ts.metaFramework ?? null,
2144
+ uiLibrary: ts.uiLibrary ?? null,
2145
+ buildTool: ts.buildTool ?? null,
2146
+ hosting: ts.hosting ?? null,
2147
+ stateManagement: ts.stateManagement ?? null,
2148
+ additional: ts.additional
2149
+ },
2150
+ externalStylesheets: event.externalStylesheets,
2151
+ externalScripts: event.externalScripts,
2152
+ preloads: event.preloads
2153
+ },
2154
+ issues,
2155
+ metadata: {
2156
+ timeRange: { from: event.timestamp, to: event.timestamp },
2157
+ eventCount: 1,
2158
+ sessionId: event.sessionId
2159
+ }
2160
+ };
2161
+ return {
2162
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2163
+ };
2164
+ }
2165
+ );
2166
+ }
2167
+
2168
+ // src/tools/recon-design-tokens.ts
2169
+ import { z as z17 } from "zod";
2170
+ function registerReconDesignTokenTools(server, store, collector) {
2171
+ server.tool(
2172
+ "get_design_tokens",
2173
+ "Extract the design system from the current page: CSS custom properties (--variables), color palette, typography scale, spacing scale, border radii, box shadows, and CSS architecture detection. Essential for matching a site's visual style when recreating UI.",
2174
+ {
2175
+ url: z17.string().optional().describe("Filter by URL substring"),
2176
+ category: z17.enum(["all", "colors", "typography", "spacing", "custom_properties", "shadows"]).optional().default("all").describe("Return only a specific token category"),
2177
+ force_refresh: z17.boolean().optional().default(false).describe("Send a recon_scan command to capture fresh data")
2178
+ },
2179
+ async ({ url, category, force_refresh }) => {
2180
+ if (force_refresh) {
2181
+ const sessions2 = store.getSessionInfo();
2182
+ const activeSession = sessions2.find((s) => s.isConnected);
2183
+ if (activeSession) {
2184
+ try {
2185
+ await collector.sendCommand(activeSession.sessionId, {
2186
+ command: "recon_scan",
2187
+ requestId: crypto.randomUUID(),
2188
+ params: { categories: ["recon_design_tokens"] }
2189
+ });
2190
+ } catch {
2191
+ }
2192
+ }
2193
+ }
2194
+ const event = store.getReconDesignTokens({ url });
2195
+ const sessions = store.getSessionInfo();
2196
+ const sessionId = sessions[0]?.sessionId ?? null;
2197
+ if (!event) {
2198
+ return {
2199
+ content: [{
2200
+ type: "text",
2201
+ text: JSON.stringify({
2202
+ summary: "No design tokens captured yet. Ensure the RuntimeScope extension is connected and has scanned a page.",
2203
+ data: null,
2204
+ issues: ["No recon_design_tokens events found in the event store"],
2205
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2206
+ }, null, 2)
2207
+ }]
2208
+ };
2209
+ }
2210
+ const issues = [];
2211
+ if (event.customProperties.length === 0) {
2212
+ issues.push("No CSS custom properties (--variables) found. The site may use hardcoded values instead of design tokens.");
2213
+ }
2214
+ if (event.colors.length > 30) {
2215
+ issues.push(`${event.colors.length} unique colors found \u2014 this may indicate an inconsistent color system.`);
2216
+ }
2217
+ if (event.typography.length > 15) {
2218
+ issues.push(`${event.typography.length} unique typography combos found \u2014 may indicate inconsistent type scale.`);
2219
+ }
2220
+ const data = {};
2221
+ if (category === "all" || category === "custom_properties") {
2222
+ data.customProperties = event.customProperties;
2223
+ }
2224
+ if (category === "all" || category === "colors") {
2225
+ data.colors = event.colors;
2226
+ }
2227
+ if (category === "all" || category === "typography") {
2228
+ data.typography = event.typography;
2229
+ }
2230
+ if (category === "all" || category === "spacing") {
2231
+ data.spacing = event.spacing;
2232
+ }
2233
+ if (category === "all" || category === "shadows") {
2234
+ data.borderRadii = event.borderRadii;
2235
+ data.boxShadows = event.boxShadows;
2236
+ }
2237
+ if (category === "all") {
2238
+ data.cssArchitecture = event.cssArchitecture;
2239
+ data.classNamingPatterns = event.classNamingPatterns;
2240
+ data.sampleClassNames = event.sampleClassNames;
2241
+ }
2242
+ const summaryParts = [
2243
+ `${event.customProperties.length} CSS variables`,
2244
+ `${event.colors.length} colors`,
2245
+ `${event.typography.length} type combos`,
2246
+ `${event.spacing.length} spacing values`,
2247
+ `CSS architecture: ${event.cssArchitecture}`
2248
+ ];
2249
+ const response = {
2250
+ summary: summaryParts.join(", ") + ".",
2251
+ data,
2252
+ issues,
2253
+ metadata: {
2254
+ timeRange: { from: event.timestamp, to: event.timestamp },
2255
+ eventCount: 1,
2256
+ sessionId: event.sessionId
2257
+ }
2258
+ };
2259
+ return {
2260
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2261
+ };
2262
+ }
2263
+ );
2264
+ }
2265
+
2266
+ // src/tools/recon-fonts.ts
2267
+ import { z as z18 } from "zod";
2268
+ function registerReconFontTools(server, store) {
2269
+ server.tool(
2270
+ "get_font_info",
2271
+ "Get typography details for the current page: @font-face declarations, font families actually used in computed styles, icon fonts with glyph usage, and font loading strategy. Critical for matching typography when recreating UI.",
2272
+ {
2273
+ url: z18.string().optional().describe("Filter by URL substring")
2274
+ },
2275
+ async ({ url }) => {
2276
+ const event = store.getReconFonts({ url });
2277
+ const sessions = store.getSessionInfo();
2278
+ const sessionId = sessions[0]?.sessionId ?? null;
2279
+ if (!event) {
2280
+ return {
2281
+ content: [{
2282
+ type: "text",
2283
+ text: JSON.stringify({
2284
+ summary: "No font data captured yet. Ensure the RuntimeScope extension is connected and has scanned a page.",
2285
+ data: null,
2286
+ issues: ["No recon_fonts events found in the event store"],
2287
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2288
+ }, null, 2)
2289
+ }]
2290
+ };
2291
+ }
2292
+ const issues = [];
2293
+ const uniqueFamilies = new Set(event.fontsUsed.map((f) => f.family));
2294
+ if (uniqueFamilies.size > 5) {
2295
+ issues.push(`${uniqueFamilies.size} different font families in use \u2014 may impact page load performance.`);
2296
+ }
2297
+ const missingDisplay = event.fontFaces.filter((f) => !f.display);
2298
+ if (missingDisplay.length > 0) {
2299
+ issues.push(`${missingDisplay.length} @font-face rule(s) without font-display \u2014 may cause FOIT (flash of invisible text).`);
2300
+ }
2301
+ const families = Array.from(uniqueFamilies).join(", ");
2302
+ const response = {
2303
+ summary: `${event.fontFaces.length} @font-face declarations, ${uniqueFamilies.size} font families in use (${families}), ${event.iconFonts.length} icon font(s). Loading: ${event.loadingStrategy}.`,
2304
+ data: {
2305
+ fontFaces: event.fontFaces,
2306
+ fontsUsed: event.fontsUsed,
2307
+ iconFonts: event.iconFonts,
2308
+ loadingStrategy: event.loadingStrategy
2309
+ },
2310
+ issues,
2311
+ metadata: {
2312
+ timeRange: { from: event.timestamp, to: event.timestamp },
2313
+ eventCount: 1,
2314
+ sessionId: event.sessionId
2315
+ }
2316
+ };
2317
+ return {
2318
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2319
+ };
2320
+ }
2321
+ );
2322
+ }
2323
+
2324
+ // src/tools/recon-layout.ts
2325
+ import { z as z19 } from "zod";
2326
+ function registerReconLayoutTools(server, store, collector) {
2327
+ server.tool(
2328
+ "get_layout_tree",
2329
+ "Get the DOM structure with layout information: element tags, classes, bounding rects, display mode (flex/grid/block), flex/grid properties (direction, justify, align, gap, template columns/rows), position, and z-index. Optionally scoped to a CSS selector. Essential for understanding page structure when recreating UI.",
2330
+ {
2331
+ selector: z19.string().optional().describe('CSS selector to scope the tree (e.g., "nav", ".hero", "main"). Omit for full page.'),
2332
+ max_depth: z19.number().optional().default(10).describe("Maximum depth of the tree to return (default 10)"),
2333
+ url: z19.string().optional().describe("Filter by URL substring"),
2334
+ force_refresh: z19.boolean().optional().default(false).describe("Request fresh capture from extension")
2335
+ },
2336
+ async ({ selector, max_depth, url, force_refresh }) => {
2337
+ if (force_refresh) {
2338
+ const sessions2 = store.getSessionInfo();
2339
+ const activeSession = sessions2.find((s) => s.isConnected);
2340
+ if (activeSession) {
2341
+ try {
2342
+ await collector.sendCommand(activeSession.sessionId, {
2343
+ command: "recon_layout_tree",
2344
+ requestId: crypto.randomUUID(),
2345
+ params: { selector, maxDepth: max_depth }
2346
+ });
2347
+ } catch {
2348
+ }
2349
+ }
2350
+ }
2351
+ const event = store.getReconLayoutTree({ url });
2352
+ const sessions = store.getSessionInfo();
2353
+ const sessionId = sessions[0]?.sessionId ?? null;
2354
+ if (!event) {
2355
+ return {
2356
+ content: [{
2357
+ type: "text",
2358
+ text: JSON.stringify({
2359
+ summary: "No layout tree captured yet. Ensure the RuntimeScope extension is connected and has scanned a page.",
2360
+ data: null,
2361
+ issues: ["No recon_layout_tree events found in the event store"],
2362
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2363
+ }, null, 2)
2364
+ }]
2365
+ };
2366
+ }
2367
+ let tree = event.tree;
2368
+ let totalElements = event.totalElements;
2369
+ if (selector && !event.rootSelector) {
2370
+ const found = findNode(tree, selector);
2371
+ if (found) {
2372
+ tree = found;
2373
+ totalElements = countNodes(found);
2374
+ }
2375
+ }
2376
+ const pruned = pruneTree(tree, max_depth ?? 10);
2377
+ const issues = [];
2378
+ const flexCount = countByDisplay(tree, "flex");
2379
+ const gridCount = countByDisplay(tree, "grid");
2380
+ const response = {
2381
+ summary: `Layout tree: ${totalElements} elements, max depth ${event.maxDepth}. ${flexCount} flex containers, ${gridCount} grid containers. Viewport: ${event.viewport.width}x${event.viewport.height}.${selector ? ` Scoped to: ${selector}.` : ""}`,
2382
+ data: {
2383
+ viewport: event.viewport,
2384
+ scrollHeight: event.scrollHeight,
2385
+ rootSelector: selector ?? event.rootSelector ?? null,
2386
+ tree: pruned,
2387
+ totalElements,
2388
+ maxDepth: event.maxDepth
2389
+ },
2390
+ issues,
2391
+ metadata: {
2392
+ timeRange: { from: event.timestamp, to: event.timestamp },
2393
+ eventCount: 1,
2394
+ sessionId: event.sessionId
2395
+ }
2396
+ };
2397
+ return {
2398
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2399
+ };
2400
+ }
2401
+ );
2402
+ }
2403
+ function findNode(node, selector) {
2404
+ if (matchesSelector(node, selector)) return node;
2405
+ for (const child of node.children) {
2406
+ const found = findNode(child, selector);
2407
+ if (found) return found;
2408
+ }
2409
+ return null;
2410
+ }
2411
+ function matchesSelector(node, selector) {
2412
+ if (selector.startsWith("#") && node.id === selector.slice(1)) return true;
2413
+ if (selector.startsWith(".") && node.classList.includes(selector.slice(1))) return true;
2414
+ if (node.tag === selector.toLowerCase()) return true;
2415
+ if (selector.startsWith("[role=") && node.role === selector.slice(6, -1).replace(/"/g, "")) return true;
2416
+ return false;
2417
+ }
2418
+ function countNodes(node) {
2419
+ let count = 1;
2420
+ for (const child of node.children) {
2421
+ count += countNodes(child);
2422
+ }
2423
+ return count;
2424
+ }
2425
+ function countByDisplay(node, displayType) {
2426
+ let count = node.display?.includes(displayType) ? 1 : 0;
2427
+ for (const child of node.children) {
2428
+ count += countByDisplay(child, displayType);
2429
+ }
2430
+ return count;
2431
+ }
2432
+ function pruneTree(node, maxDepth, currentDepth = 0) {
2433
+ if (currentDepth >= maxDepth) {
2434
+ return {
2435
+ ...node,
2436
+ children: [],
2437
+ childCount: node.childCount
2438
+ };
2439
+ }
2440
+ return {
2441
+ ...node,
2442
+ children: node.children.map((c) => pruneTree(c, maxDepth, currentDepth + 1))
2443
+ };
2444
+ }
2445
+
2446
+ // src/tools/recon-accessibility.ts
2447
+ import { z as z20 } from "zod";
2448
+ function registerReconAccessibilityTools(server, store) {
2449
+ server.tool(
2450
+ "get_accessibility_tree",
2451
+ "Get the accessibility structure of the current page: heading hierarchy (h1-h6), ARIA landmarks (nav, main, aside), form fields with labels, buttons, links, and images with alt text status. Useful for ensuring UI recreations maintain proper semantic HTML and accessibility.",
2452
+ {
2453
+ url: z20.string().optional().describe("Filter by URL substring")
2454
+ },
2455
+ async ({ url }) => {
2456
+ const event = store.getReconAccessibility({ url });
2457
+ const sessions = store.getSessionInfo();
2458
+ const sessionId = sessions[0]?.sessionId ?? null;
2459
+ if (!event) {
2460
+ return {
2461
+ content: [{
2462
+ type: "text",
2463
+ text: JSON.stringify({
2464
+ summary: "No accessibility data captured yet. Ensure the RuntimeScope extension is connected and has scanned a page.",
2465
+ data: null,
2466
+ issues: ["No recon_accessibility events found in the event store"],
2467
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2468
+ }, null, 2)
2469
+ }]
2470
+ };
2471
+ }
2472
+ const issues = [...event.issues];
2473
+ const headingLevels = event.headings.map((h) => h.level);
2474
+ if (headingLevels.length > 0 && headingLevels[0] !== 1) {
2475
+ issues.push(`First heading is h${headingLevels[0]}, not h1.`);
2476
+ }
2477
+ for (let i = 1; i < headingLevels.length; i++) {
2478
+ if (headingLevels[i] > headingLevels[i - 1] + 1) {
2479
+ issues.push(`Heading level skip: h${headingLevels[i - 1]} \u2192 h${headingLevels[i]} (missing h${headingLevels[i - 1] + 1}).`);
2480
+ break;
2481
+ }
2482
+ }
2483
+ const missingAlt = event.images.filter((img) => !img.hasAlt);
2484
+ if (missingAlt.length > 0) {
2485
+ issues.push(`${missingAlt.length} image(s) missing alt text.`);
2486
+ }
2487
+ const unlabeled = event.formFields.filter((f) => !f.label && !f.ariaDescribedBy);
2488
+ if (unlabeled.length > 0) {
2489
+ issues.push(`${unlabeled.length} form field(s) without labels.`);
2490
+ }
2491
+ const hasMain = event.landmarks.some((l) => l.role === "main");
2492
+ const hasNav = event.landmarks.some((l) => l.role === "navigation");
2493
+ if (!hasMain) issues.push("No <main> landmark found.");
2494
+ if (!hasNav) issues.push("No <nav> landmark found.");
2495
+ const response = {
2496
+ summary: `${event.headings.length} headings, ${event.landmarks.length} landmarks, ${event.formFields.length} form fields, ${event.buttons.length} buttons, ${event.links.length} links, ${event.images.length} images. ${issues.length} accessibility issue(s).`,
2497
+ data: {
2498
+ headings: event.headings,
2499
+ landmarks: event.landmarks,
2500
+ formFields: event.formFields,
2501
+ buttons: event.buttons,
2502
+ links: event.links,
2503
+ images: event.images
2504
+ },
2505
+ issues,
2506
+ metadata: {
2507
+ timeRange: { from: event.timestamp, to: event.timestamp },
2508
+ eventCount: 1,
2509
+ sessionId: event.sessionId
2510
+ }
2511
+ };
2512
+ return {
2513
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2514
+ };
2515
+ }
2516
+ );
2517
+ }
2518
+
2519
+ // src/tools/recon-computed-styles.ts
2520
+ import { z as z21 } from "zod";
2521
+ var PROPERTY_GROUPS = {
2522
+ colors: [
2523
+ "color",
2524
+ "background-color",
2525
+ "border-color",
2526
+ "border-top-color",
2527
+ "border-right-color",
2528
+ "border-bottom-color",
2529
+ "border-left-color",
2530
+ "outline-color",
2531
+ "text-decoration-color",
2532
+ "box-shadow",
2533
+ "text-shadow"
2534
+ ],
2535
+ typography: [
2536
+ "font-family",
2537
+ "font-size",
2538
+ "font-weight",
2539
+ "font-style",
2540
+ "line-height",
2541
+ "letter-spacing",
2542
+ "text-align",
2543
+ "text-transform",
2544
+ "text-decoration",
2545
+ "word-spacing",
2546
+ "white-space",
2547
+ "text-overflow"
2548
+ ],
2549
+ spacing: [
2550
+ "margin-top",
2551
+ "margin-right",
2552
+ "margin-bottom",
2553
+ "margin-left",
2554
+ "padding-top",
2555
+ "padding-right",
2556
+ "padding-bottom",
2557
+ "padding-left",
2558
+ "gap"
2559
+ ],
2560
+ layout: [
2561
+ "display",
2562
+ "position",
2563
+ "top",
2564
+ "right",
2565
+ "bottom",
2566
+ "left",
2567
+ "width",
2568
+ "height",
2569
+ "min-width",
2570
+ "max-width",
2571
+ "min-height",
2572
+ "max-height",
2573
+ "flex-direction",
2574
+ "justify-content",
2575
+ "align-items",
2576
+ "flex-wrap",
2577
+ "flex-grow",
2578
+ "flex-shrink",
2579
+ "grid-template-columns",
2580
+ "grid-template-rows",
2581
+ "grid-column",
2582
+ "grid-row",
2583
+ "overflow",
2584
+ "z-index"
2585
+ ],
2586
+ borders: [
2587
+ "border-width",
2588
+ "border-style",
2589
+ "border-color",
2590
+ "border-radius",
2591
+ "border-top-width",
2592
+ "border-right-width",
2593
+ "border-bottom-width",
2594
+ "border-left-width",
2595
+ "border-top-left-radius",
2596
+ "border-top-right-radius",
2597
+ "border-bottom-right-radius",
2598
+ "border-bottom-left-radius",
2599
+ "outline-width",
2600
+ "outline-style",
2601
+ "outline-color",
2602
+ "outline-offset"
2603
+ ],
2604
+ visual: [
2605
+ "opacity",
2606
+ "background-color",
2607
+ "background-image",
2608
+ "background-size",
2609
+ "background-position",
2610
+ "box-shadow",
2611
+ "text-shadow",
2612
+ "filter",
2613
+ "backdrop-filter",
2614
+ "transform",
2615
+ "transition",
2616
+ "animation"
2617
+ ]
2618
+ };
2619
+ function registerReconComputedStyleTools(server, store, collector, scanner) {
2620
+ server.tool(
2621
+ "get_computed_styles",
2622
+ "Get computed CSS styles for elements matching a selector. Returns the actual resolved values the browser uses to render each element. Can filter by property group (colors, typography, spacing, layout, borders, visual) or specific property names. When multiple elements match, highlights variations between them.",
2623
+ {
2624
+ selector: z21.string().describe('CSS selector to query (e.g., ".btn-primary", "nav > ul > li", "[data-testid=hero]")'),
2625
+ properties: z21.enum(["all", "colors", "typography", "spacing", "layout", "borders", "visual"]).optional().default("all").describe('Property group to return, or "all" for everything'),
2626
+ specific_properties: z21.array(z21.string()).optional().describe("Specific CSS property names to return (overrides the properties group)"),
2627
+ force_refresh: z21.boolean().optional().default(false).describe("Request fresh capture from extension or scanner for this selector")
2628
+ },
2629
+ async ({ selector, properties, specific_properties, force_refresh }) => {
2630
+ const propFilter = specific_properties ?? (properties !== "all" ? PROPERTY_GROUPS[properties] : void 0);
2631
+ if (force_refresh) {
2632
+ const sessions2 = store.getSessionInfo();
2633
+ const activeSession = sessions2.find((s) => s.isConnected);
2634
+ if (activeSession) {
2635
+ try {
2636
+ await collector.sendCommand(activeSession.sessionId, {
2637
+ command: "recon_computed_styles",
2638
+ requestId: crypto.randomUUID(),
2639
+ params: { selector, properties: propFilter }
2640
+ });
2641
+ } catch {
2642
+ }
2643
+ }
2644
+ }
2645
+ const events = store.getReconComputedStyles();
2646
+ let event = events.find((e) => e.selector === selector) ?? events[0];
2647
+ if ((!event || event.entries.length === 0) && scanner.getLastScannedUrl()) {
2648
+ const url = scanner.getLastScannedUrl();
2649
+ try {
2650
+ const raw = await scanner.queryComputedStyles(url, selector, propFilter);
2651
+ if (raw.entries.length > 0) {
2652
+ const syntheticEvent = {
2653
+ eventId: `evt-scan-cs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2654
+ sessionId: `scan-${Date.now()}`,
2655
+ timestamp: Date.now(),
2656
+ eventType: "recon_computed_styles",
2657
+ url,
2658
+ selector: raw.selector,
2659
+ propertyFilter: raw.propertyFilter,
2660
+ entries: raw.entries
2661
+ };
2662
+ store.addEvent(syntheticEvent);
2663
+ event = syntheticEvent;
2664
+ }
2665
+ } catch {
2666
+ }
2667
+ }
2668
+ const sessions = store.getSessionInfo();
2669
+ const sessionId = sessions[0]?.sessionId ?? null;
2670
+ if (!event || event.entries.length === 0) {
2671
+ const hint = scanner.getLastScannedUrl() ? `No elements matched "${selector}" on the scanned page. Check the selector and try again.` : `No computed styles captured for "${selector}". Run scan_website first to scan a page, then query selectors on it.`;
2672
+ return {
2673
+ content: [{
2674
+ type: "text",
2675
+ text: JSON.stringify({
2676
+ summary: hint,
2677
+ data: null,
2678
+ issues: ["No computed style data available for this selector"],
2679
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2680
+ }, null, 2)
2681
+ }]
2682
+ };
2683
+ }
2684
+ const issues = [];
2685
+ const entries = event.entries.map((entry) => {
2686
+ let styles = entry.styles;
2687
+ if (specific_properties && specific_properties.length > 0) {
2688
+ const filtered = {};
2689
+ for (const prop of specific_properties) {
2690
+ if (styles[prop] !== void 0) filtered[prop] = styles[prop];
2691
+ }
2692
+ styles = filtered;
2693
+ } else if (properties !== "all" && PROPERTY_GROUPS[properties]) {
2694
+ const group = PROPERTY_GROUPS[properties];
2695
+ const filtered = {};
2696
+ for (const prop of group) {
2697
+ if (styles[prop] !== void 0) filtered[prop] = styles[prop];
2698
+ }
2699
+ styles = filtered;
2700
+ }
2701
+ return {
2702
+ selector: entry.selector,
2703
+ matchCount: entry.matchCount,
2704
+ styles,
2705
+ variations: entry.variations ?? []
2706
+ };
2707
+ });
2708
+ for (const entry of entries) {
2709
+ if (entry.variations.length > 0) {
2710
+ issues.push(
2711
+ `${entry.variations.length} property variation(s) across ${entry.matchCount} matching elements for "${entry.selector}".`
2712
+ );
2713
+ }
2714
+ }
2715
+ const totalProps = entries.reduce((sum, e) => sum + Object.keys(e.styles).length, 0);
2716
+ const response = {
2717
+ summary: `${entries.length} element(s) matched "${selector}". ${totalProps} CSS properties returned${properties !== "all" ? ` (${properties} group)` : ""}.`,
2718
+ data: {
2719
+ selector,
2720
+ propertyFilter: specific_properties ?? properties,
2721
+ entries
2722
+ },
2723
+ issues,
2724
+ metadata: {
2725
+ timeRange: { from: event.timestamp, to: event.timestamp },
2726
+ eventCount: 1,
2727
+ sessionId: event.sessionId
2728
+ }
2729
+ };
2730
+ return {
2731
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2732
+ };
2733
+ }
2734
+ );
2735
+ }
2736
+
2737
+ // src/tools/recon-element-snapshot.ts
2738
+ import { z as z22 } from "zod";
2739
+ function registerReconElementSnapshotTools(server, store, collector, scanner) {
2740
+ server.tool(
2741
+ "get_element_snapshot",
2742
+ 'Deep snapshot of a specific element and its children: structure, attributes, text content, bounding rects, and key computed styles for every node. This is the "zoom in" tool \u2014 use it when you need the full picture of a component (a card, a nav bar, a form) for recreation. More detailed than get_layout_tree, more targeted than get_computed_styles.',
2743
+ {
2744
+ selector: z22.string().describe('CSS selector for the root element (e.g., ".card", "#hero", "[data-testid=checkout-form]")'),
2745
+ depth: z22.number().optional().default(5).describe("How many levels deep to capture children (default 5)"),
2746
+ force_refresh: z22.boolean().optional().default(false).describe("Request fresh capture from extension or scanner for this element")
2747
+ },
2748
+ async ({ selector, depth, force_refresh }) => {
2749
+ if (force_refresh) {
2750
+ const sessions2 = store.getSessionInfo();
2751
+ const activeSession = sessions2.find((s) => s.isConnected);
2752
+ if (activeSession) {
2753
+ try {
2754
+ await collector.sendCommand(activeSession.sessionId, {
2755
+ command: "recon_element_snapshot",
2756
+ requestId: crypto.randomUUID(),
2757
+ params: { selector, depth }
2758
+ });
2759
+ } catch {
2760
+ }
2761
+ }
2762
+ }
2763
+ const events = store.getReconElementSnapshots();
2764
+ let event = events.find((e) => e.selector === selector) ?? events[0];
2765
+ if (!event && scanner.getLastScannedUrl()) {
2766
+ const url = scanner.getLastScannedUrl();
2767
+ try {
2768
+ const raw = await scanner.queryElementSnapshot(url, selector, depth);
2769
+ if (raw) {
2770
+ const syntheticEvent = {
2771
+ eventId: `evt-scan-es-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2772
+ sessionId: `scan-${Date.now()}`,
2773
+ timestamp: Date.now(),
2774
+ eventType: "recon_element_snapshot",
2775
+ url,
2776
+ selector: raw.selector,
2777
+ depth: raw.depth,
2778
+ totalNodes: raw.totalNodes,
2779
+ root: raw.root
2780
+ };
2781
+ store.addEvent(syntheticEvent);
2782
+ event = syntheticEvent;
2783
+ }
2784
+ } catch {
2785
+ }
2786
+ }
2787
+ const sessions = store.getSessionInfo();
2788
+ const sessionId = sessions[0]?.sessionId ?? null;
2789
+ if (!event) {
2790
+ const hint = scanner.getLastScannedUrl() ? `No element found matching "${selector}" on the scanned page. Check the selector and try again.` : `No element snapshot captured for "${selector}". Run scan_website first to scan a page, then query selectors on it.`;
2791
+ return {
2792
+ content: [{
2793
+ type: "text",
2794
+ text: JSON.stringify({
2795
+ summary: hint,
2796
+ data: null,
2797
+ issues: ["No element snapshot data available for this selector"],
2798
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2799
+ }, null, 2)
2800
+ }]
2801
+ };
2802
+ }
2803
+ const issues = [];
2804
+ const root = event.root;
2805
+ if (root.boundingRect.width === 0 || root.boundingRect.height === 0) {
2806
+ issues.push(`Root element "${selector}" has zero dimensions (${root.boundingRect.width}x${root.boundingRect.height}). It may be hidden.`);
2807
+ }
2808
+ const response = {
2809
+ summary: `Element snapshot for "${selector}": ${event.totalNodes} nodes captured to depth ${event.depth}. Root is <${root.tag}> at ${root.boundingRect.width}x${root.boundingRect.height}px.`,
2810
+ data: {
2811
+ selector: event.selector,
2812
+ depth: event.depth,
2813
+ totalNodes: event.totalNodes,
2814
+ root: event.root
2815
+ },
2816
+ issues,
2817
+ metadata: {
2818
+ timeRange: { from: event.timestamp, to: event.timestamp },
2819
+ eventCount: 1,
2820
+ sessionId: event.sessionId
2821
+ }
2822
+ };
2823
+ return {
2824
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2825
+ };
2826
+ }
2827
+ );
2828
+ }
2829
+
2830
+ // src/tools/recon-assets.ts
2831
+ import { z as z23 } from "zod";
2832
+ function registerReconAssetTools(server, store) {
2833
+ server.tool(
2834
+ "get_asset_inventory",
2835
+ "Sprite-aware asset inventory for the current page. Detects and extracts: standard images, inline SVGs, SVG sprite sheets (<symbol>/<use> references), CSS background sprites (with crop coordinates and extracted frames), CSS mask sprites, and icon fonts (with glyph codepoints). For CSS sprites, calculates the exact crop rectangle from background-position/size and can provide extracted individual frames as data URLs.",
2836
+ {
2837
+ category: z23.enum(["all", "images", "svg", "sprites", "icon_fonts"]).optional().default("all").describe("Filter by asset category"),
2838
+ url: z23.string().optional().describe("Filter by page URL substring")
2839
+ },
2840
+ async ({ category, url }) => {
2841
+ const event = store.getReconAssetInventory({ url });
2842
+ const sessions = store.getSessionInfo();
2843
+ const sessionId = sessions[0]?.sessionId ?? null;
2844
+ if (!event) {
2845
+ return {
2846
+ content: [{
2847
+ type: "text",
2848
+ text: JSON.stringify({
2849
+ summary: "No asset inventory captured yet. Ensure the RuntimeScope extension is connected and has scanned a page.",
2850
+ data: null,
2851
+ issues: ["No recon_asset_inventory events found in the event store"],
2852
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
2853
+ }, null, 2)
2854
+ }]
2855
+ };
2856
+ }
2857
+ const issues = [];
2858
+ const data = {};
2859
+ if (category === "all" || category === "images") {
2860
+ data.images = event.images;
2861
+ const missingAlt = event.images.filter((img) => !img.alt);
2862
+ if (missingAlt.length > 0) {
2863
+ issues.push(`${missingAlt.length} image(s) missing alt text.`);
2864
+ }
2865
+ const oversized = event.images.filter(
2866
+ (img) => img.naturalWidth && img.width && img.naturalWidth > img.width * 2
2867
+ );
2868
+ if (oversized.length > 0) {
2869
+ issues.push(`${oversized.length} image(s) are significantly larger than their display size \u2014 consider resizing.`);
2870
+ }
2871
+ }
2872
+ if (category === "all" || category === "svg") {
2873
+ data.inlineSVGs = event.inlineSVGs;
2874
+ data.svgSprites = event.svgSprites;
2875
+ }
2876
+ if (category === "all" || category === "sprites") {
2877
+ data.backgroundSprites = event.backgroundSprites;
2878
+ data.maskSprites = event.maskSprites;
2879
+ data.svgSprites = event.svgSprites;
2880
+ const totalBgFrames = event.backgroundSprites.reduce((sum, s) => sum + s.frames.length, 0);
2881
+ const totalMaskFrames = event.maskSprites.reduce((sum, s) => sum + s.frames.length, 0);
2882
+ const totalSvgSymbols = event.svgSprites.length;
2883
+ if (totalBgFrames > 0 || totalMaskFrames > 0 || totalSvgSymbols > 0) {
2884
+ const spriteParts = [];
2885
+ if (totalBgFrames > 0) spriteParts.push(`${totalBgFrames} background sprite frame(s) from ${event.backgroundSprites.length} sheet(s)`);
2886
+ if (totalMaskFrames > 0) spriteParts.push(`${totalMaskFrames} mask sprite frame(s) from ${event.maskSprites.length} sheet(s)`);
2887
+ if (totalSvgSymbols > 0) spriteParts.push(`${totalSvgSymbols} SVG symbol(s)`);
2888
+ issues.push(`Sprite detection: ${spriteParts.join(", ")}.`);
2889
+ }
2890
+ }
2891
+ if (category === "all" || category === "icon_fonts") {
2892
+ data.iconFonts = event.iconFonts;
2893
+ const totalGlyphs2 = event.iconFonts.reduce((sum, f) => sum + f.glyphs.length, 0);
2894
+ if (totalGlyphs2 > 0) {
2895
+ issues.push(`${totalGlyphs2} icon font glyph(s) from ${event.iconFonts.length} font(s) detected.`);
2896
+ }
2897
+ }
2898
+ const summaryParts = [];
2899
+ summaryParts.push(`${event.images.length} images`);
2900
+ summaryParts.push(`${event.inlineSVGs.length} inline SVGs`);
2901
+ const bgFrames = event.backgroundSprites.reduce((sum, s) => sum + s.frames.length, 0);
2902
+ if (bgFrames > 0) summaryParts.push(`${bgFrames} CSS sprite frames`);
2903
+ if (event.svgSprites.length > 0) summaryParts.push(`${event.svgSprites.length} SVG symbols`);
2904
+ if (event.maskSprites.length > 0) {
2905
+ const maskFrames = event.maskSprites.reduce((sum, s) => sum + s.frames.length, 0);
2906
+ summaryParts.push(`${maskFrames} mask sprite frames`);
2907
+ }
2908
+ const totalGlyphs = event.iconFonts.reduce((sum, f) => sum + f.glyphs.length, 0);
2909
+ if (totalGlyphs > 0) summaryParts.push(`${totalGlyphs} icon font glyphs`);
2910
+ summaryParts.push(`${event.totalAssets} total assets`);
2911
+ const response = {
2912
+ summary: summaryParts.join(", ") + ".",
2913
+ data,
2914
+ issues,
2915
+ metadata: {
2916
+ timeRange: { from: event.timestamp, to: event.timestamp },
2917
+ eventCount: 1,
2918
+ sessionId: event.sessionId
2919
+ }
2920
+ };
2921
+ return {
2922
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2923
+ };
2924
+ }
2925
+ );
2926
+ }
2927
+
2928
+ // src/tools/recon-style-diff.ts
2929
+ import { z as z24 } from "zod";
2930
+ var VISUAL_PROPERTIES = [
2931
+ "color",
2932
+ "background-color",
2933
+ "border-color",
2934
+ "border-radius",
2935
+ "font-family",
2936
+ "font-size",
2937
+ "font-weight",
2938
+ "line-height",
2939
+ "letter-spacing",
2940
+ "padding-top",
2941
+ "padding-right",
2942
+ "padding-bottom",
2943
+ "padding-left",
2944
+ "margin-top",
2945
+ "margin-right",
2946
+ "margin-bottom",
2947
+ "margin-left",
2948
+ "width",
2949
+ "height",
2950
+ "display",
2951
+ "position",
2952
+ "gap",
2953
+ "flex-direction",
2954
+ "justify-content",
2955
+ "align-items",
2956
+ "box-shadow",
2957
+ "text-shadow",
2958
+ "opacity",
2959
+ "border-width",
2960
+ "border-style",
2961
+ "text-align",
2962
+ "text-transform",
2963
+ "text-decoration",
2964
+ "overflow",
2965
+ "grid-template-columns",
2966
+ "grid-template-rows"
2967
+ ];
2968
+ function registerReconStyleDiffTools(server, store) {
2969
+ server.tool(
2970
+ "get_style_diff",
2971
+ "Compare computed styles between two captured element snapshots to check how closely a recreation matches the original. Compares two selectors from stored computed style events and reports property-by-property differences with a match percentage. Use this to verify UI recreation fidelity.",
2972
+ {
2973
+ source_selector: z24.string().describe("CSS selector for the source/original element"),
2974
+ target_selector: z24.string().describe("CSS selector for the target/recreation element"),
2975
+ properties: z24.enum(["visual", "all"]).optional().default("visual").describe('"visual" compares only visually-significant properties (colors, typography, spacing, layout). "all" compares everything.'),
2976
+ specific_properties: z24.array(z24.string()).optional().describe("Specific CSS property names to compare (overrides properties group)")
2977
+ },
2978
+ async ({ source_selector, target_selector, properties, specific_properties }) => {
2979
+ const events = store.getReconComputedStyles();
2980
+ const sessions = store.getSessionInfo();
2981
+ const sessionId = sessions[0]?.sessionId ?? null;
2982
+ const sourceEvent = events.find(
2983
+ (e) => e.entries.some((entry) => entry.selector === source_selector)
2984
+ );
2985
+ const targetEvent = events.find(
2986
+ (e) => e.entries.some((entry) => entry.selector === target_selector)
2987
+ );
2988
+ if (!sourceEvent || !targetEvent) {
2989
+ const missing = [];
2990
+ if (!sourceEvent) missing.push(`source "${source_selector}"`);
2991
+ if (!targetEvent) missing.push(`target "${target_selector}"`);
2992
+ return {
2993
+ content: [{
2994
+ type: "text",
2995
+ text: JSON.stringify({
2996
+ summary: `Missing computed styles for ${missing.join(" and ")}. Capture computed styles for both selectors first using get_computed_styles with force_refresh=true.`,
2997
+ data: null,
2998
+ issues: [`No captured styles for: ${missing.join(", ")}`],
2999
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId }
3000
+ }, null, 2)
3001
+ }]
3002
+ };
3003
+ }
3004
+ const sourceEntry = sourceEvent.entries.find((e) => e.selector === source_selector);
3005
+ const targetEntry = targetEvent.entries.find((e) => e.selector === target_selector);
3006
+ let propsToCompare;
3007
+ if (specific_properties && specific_properties.length > 0) {
3008
+ propsToCompare = specific_properties;
3009
+ } else if (properties === "visual") {
3010
+ propsToCompare = VISUAL_PROPERTIES;
3011
+ } else {
3012
+ propsToCompare = Array.from(
3013
+ /* @__PURE__ */ new Set([...Object.keys(sourceEntry.styles), ...Object.keys(targetEntry.styles)])
3014
+ );
3015
+ }
3016
+ const diffs = [];
3017
+ let matchCount = 0;
3018
+ let diffCount = 0;
3019
+ for (const prop of propsToCompare) {
3020
+ const sourceVal = sourceEntry.styles[prop] ?? "(not set)";
3021
+ const targetVal = targetEntry.styles[prop] ?? "(not set)";
3022
+ const match = normalizeValue(sourceVal) === normalizeValue(targetVal);
3023
+ if (match) matchCount++;
3024
+ else diffCount++;
3025
+ const entry = { property: prop, sourceValue: sourceVal, targetValue: targetVal, match };
3026
+ if (!match) {
3027
+ const sourceNum = parseNumericValue(sourceVal);
3028
+ const targetNum = parseNumericValue(targetVal);
3029
+ if (sourceNum !== null && targetNum !== null) {
3030
+ const diff = targetNum - sourceNum;
3031
+ entry.delta = `${diff > 0 ? "+" : ""}${diff.toFixed(1)}px`;
3032
+ }
3033
+ }
3034
+ diffs.push(entry);
3035
+ }
3036
+ const matchPercentage = propsToCompare.length > 0 ? Math.round(matchCount / propsToCompare.length * 100) : 100;
3037
+ const issues = [];
3038
+ const significantDiffs = diffs.filter((d) => !d.match);
3039
+ if (significantDiffs.length > 0) {
3040
+ const topDiffs = significantDiffs.slice(0, 10);
3041
+ for (const d of topDiffs) {
3042
+ issues.push(`${d.property}: "${d.sourceValue}" \u2192 "${d.targetValue}"${d.delta ? ` (${d.delta})` : ""}`);
3043
+ }
3044
+ if (significantDiffs.length > 10) {
3045
+ issues.push(`...and ${significantDiffs.length - 10} more differences.`);
3046
+ }
3047
+ }
3048
+ const response = {
3049
+ summary: `Style comparison: ${matchPercentage}% match (${matchCount}/${propsToCompare.length} properties). ${diffCount} difference(s) between "${source_selector}" and "${target_selector}".`,
3050
+ data: {
3051
+ sourceSelector: source_selector,
3052
+ targetSelector: target_selector,
3053
+ matchPercentage,
3054
+ totalProperties: propsToCompare.length,
3055
+ matches: matchCount,
3056
+ differences: diffCount,
3057
+ diffs: diffs.filter((d) => !d.match),
3058
+ // Only return differences
3059
+ matchingProperties: diffs.filter((d) => d.match).map((d) => d.property)
3060
+ },
3061
+ issues,
3062
+ metadata: {
3063
+ timeRange: {
3064
+ from: Math.min(sourceEvent.timestamp, targetEvent.timestamp),
3065
+ to: Math.max(sourceEvent.timestamp, targetEvent.timestamp)
3066
+ },
3067
+ eventCount: 2,
3068
+ sessionId
3069
+ }
3070
+ };
3071
+ return {
3072
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
3073
+ };
3074
+ }
3075
+ );
3076
+ }
3077
+ function normalizeValue(value) {
3078
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
3079
+ }
3080
+ function parseNumericValue(value) {
3081
+ const match = value.match(/^(-?[\d.]+)\s*(px|rem|em|%)$/);
3082
+ if (!match) return null;
3083
+ const num = parseFloat(match[1]);
3084
+ const unit = match[2];
3085
+ if (unit === "rem" || unit === "em") return num * 16;
3086
+ return num;
3087
+ }
3088
+
3089
+ // src/scanner/index.ts
3090
+ import { readFileSync } from "fs";
3091
+ import { resolve, dirname } from "path";
3092
+ import { fileURLToPath } from "url";
3093
+ import { TechnologyDatabase, detect } from "@runtimescope/extension";
3094
+
3095
+ // src/scanner/signal-collector.ts
3096
+ async function collectDetectionSignals(page, mainResponse, jsGlobalPaths, domSelectors) {
3097
+ const url = page.url();
3098
+ const headers = {};
3099
+ if (mainResponse) {
3100
+ const allHeaders = await mainResponse.allHeaders();
3101
+ for (const [key, value] of Object.entries(allHeaders)) {
3102
+ headers[key.toLowerCase()] = value;
3103
+ }
3104
+ }
3105
+ const cookies = {};
3106
+ const rawCookies = await page.context().cookies();
3107
+ for (const c of rawCookies) {
3108
+ cookies[c.name] = c.value;
3109
+ }
3110
+ const pageSignals = await page.evaluate(() => {
3111
+ const meta = {};
3112
+ document.querySelectorAll("meta[name],meta[property],meta[http-equiv]").forEach((el) => {
3113
+ const name = el.getAttribute("name") || el.getAttribute("property") || el.getAttribute("http-equiv");
3114
+ const content = el.getAttribute("content");
3115
+ if (name && content) meta[name.toLowerCase()] = content;
3116
+ });
3117
+ const scriptSrc = [];
3118
+ document.querySelectorAll("script[src]").forEach((el) => {
3119
+ const src = el.getAttribute("src");
3120
+ if (src) scriptSrc.push(src);
3121
+ });
3122
+ const scripts = [];
3123
+ document.querySelectorAll("script:not([src])").forEach((el) => {
3124
+ const text = el.textContent?.trim();
3125
+ if (text && scripts.length < 50) {
3126
+ scripts.push(text.slice(0, 500));
3127
+ }
3128
+ });
3129
+ const css = [];
3130
+ const rootStyles = getComputedStyle(document.documentElement);
3131
+ const cssVarNames = [];
3132
+ for (let i = 0; i < rootStyles.length; i++) {
3133
+ const prop = rootStyles[i];
3134
+ if (prop.startsWith("--")) cssVarNames.push(prop);
3135
+ }
3136
+ if (cssVarNames.length > 0) {
3137
+ css.push(cssVarNames.map((v) => `${v}: ${rootStyles.getPropertyValue(v)}`).join("; "));
3138
+ }
3139
+ document.querySelectorAll("style").forEach((el) => {
3140
+ const text = el.textContent?.trim();
3141
+ if (text && css.length < 20) css.push(text.slice(0, 2e3));
3142
+ });
3143
+ return { meta, scriptSrc, scripts, css };
3144
+ });
3145
+ const fullHtml = await page.content();
3146
+ const html = fullHtml.slice(0, 5e4);
3147
+ const js = await page.evaluate((paths) => {
3148
+ const results = {};
3149
+ for (const path of paths) {
3150
+ try {
3151
+ const parts = path.split(".");
3152
+ let current = window;
3153
+ for (const part of parts) {
3154
+ if (current == null) break;
3155
+ current = current[part];
3156
+ }
3157
+ if (current !== void 0) {
3158
+ results[path] = typeof current === "string" ? current : typeof current === "number" ? String(current) : "";
3159
+ }
3160
+ } catch {
3161
+ }
3162
+ }
3163
+ return results;
3164
+ }, jsGlobalPaths);
3165
+ const dom = await page.evaluate((selectors) => {
3166
+ const results = {};
3167
+ for (const selector of selectors) {
3168
+ try {
3169
+ const elements = document.querySelectorAll(selector);
3170
+ if (elements.length === 0) continue;
3171
+ const elResults = [];
3172
+ const limit = Math.min(elements.length, 3);
3173
+ for (let i = 0; i < limit; i++) {
3174
+ const el = elements[i];
3175
+ const attributes = {};
3176
+ for (const attr of el.attributes) {
3177
+ attributes[attr.name] = attr.value;
3178
+ }
3179
+ const properties = {};
3180
+ const propsToCheck = ["_reactRootContainer", "__vue__", "__svelte", "ng-version"];
3181
+ for (const prop of propsToCheck) {
3182
+ if (prop in el) {
3183
+ properties[prop] = String(el[prop] ?? "");
3184
+ }
3185
+ }
3186
+ elResults.push({
3187
+ exists: true,
3188
+ attributes,
3189
+ properties,
3190
+ text: (el.textContent || "").trim().slice(0, 200)
3191
+ });
3192
+ }
3193
+ results[selector] = elResults;
3194
+ } catch {
3195
+ }
3196
+ }
3197
+ return results;
3198
+ }, domSelectors);
3199
+ return {
3200
+ url,
3201
+ headers,
3202
+ cookies,
3203
+ meta: pageSignals.meta,
3204
+ scriptSrc: pageSignals.scriptSrc,
3205
+ scripts: pageSignals.scripts,
3206
+ html,
3207
+ css: pageSignals.css,
3208
+ js,
3209
+ dom
3210
+ // xhr: not collected in one-shot scan (would need request interception over time)
3211
+ };
3212
+ }
3213
+ function extractJsGlobalPaths(technologies) {
3214
+ const paths = /* @__PURE__ */ new Set();
3215
+ for (const tech of technologies) {
3216
+ if (tech.js) {
3217
+ for (const path of Object.keys(tech.js)) {
3218
+ paths.add(path);
3219
+ }
3220
+ }
3221
+ }
3222
+ return Array.from(paths);
3223
+ }
3224
+ function extractDomSelectors(technologies) {
3225
+ const selectors = /* @__PURE__ */ new Set();
3226
+ for (const tech of technologies) {
3227
+ if (tech.dom) {
3228
+ for (const selector of Object.keys(tech.dom)) {
3229
+ selectors.add(selector);
3230
+ }
3231
+ }
3232
+ }
3233
+ return Array.from(selectors);
3234
+ }
3235
+
3236
+ // src/scanner/recon-collectors.ts
3237
+ async function collectDesignTokens(page) {
3238
+ return page.evaluate(() => {
3239
+ const rootStyle = getComputedStyle(document.documentElement);
3240
+ const customProperties = [];
3241
+ for (let i = 0; i < rootStyle.length; i++) {
3242
+ const prop = rootStyle[i];
3243
+ if (prop.startsWith("--")) {
3244
+ customProperties.push({ name: prop, value: rootStyle.getPropertyValue(prop).trim(), source: ":root" });
3245
+ }
3246
+ }
3247
+ const colorMap = /* @__PURE__ */ new Map();
3248
+ const typoMap = /* @__PURE__ */ new Map();
3249
+ const spacingMap = /* @__PURE__ */ new Map();
3250
+ const radiusMap = /* @__PURE__ */ new Map();
3251
+ const shadowMap = /* @__PURE__ */ new Map();
3252
+ const allElements = document.querySelectorAll("body *");
3253
+ const sampleLimit = Math.min(allElements.length, 200);
3254
+ for (let i = 0; i < sampleLimit; i++) {
3255
+ const el = allElements[i];
3256
+ const elRect = el.getBoundingClientRect();
3257
+ if (elRect.width === 0 && elRect.height === 0) continue;
3258
+ const cs = getComputedStyle(el);
3259
+ const cls = el.getAttribute("class") || "";
3260
+ const selector = el.tagName.toLowerCase() + (cls ? "." + cls.split(" ")[0] : "");
3261
+ for (const prop of ["color", "background-color", "border-color"]) {
3262
+ const val = cs.getPropertyValue(prop);
3263
+ if (val && val !== "rgba(0, 0, 0, 0)" && val !== "transparent") {
3264
+ const entry = colorMap.get(val) || { count: 0, properties: /* @__PURE__ */ new Set(), selectors: /* @__PURE__ */ new Set() };
3265
+ entry.count++;
3266
+ entry.properties.add(prop);
3267
+ if (entry.selectors.size < 3) entry.selectors.add(selector);
3268
+ colorMap.set(val, entry);
3269
+ }
3270
+ }
3271
+ const typoKey = `${cs.fontFamily}|${cs.fontSize}|${cs.fontWeight}|${cs.lineHeight}|${cs.letterSpacing}`;
3272
+ const typoEntry = typoMap.get(typoKey) || {
3273
+ count: 0,
3274
+ selectors: /* @__PURE__ */ new Set(),
3275
+ parsed: { fontFamily: cs.fontFamily, fontSize: cs.fontSize, fontWeight: cs.fontWeight, lineHeight: cs.lineHeight, letterSpacing: cs.letterSpacing }
3276
+ };
3277
+ typoEntry.count++;
3278
+ if (typoEntry.selectors.size < 3) typoEntry.selectors.add(selector);
3279
+ typoMap.set(typoKey, typoEntry);
3280
+ for (const prop of ["padding-top", "padding-right", "padding-bottom", "padding-left", "margin-top", "margin-right", "margin-bottom", "margin-left", "gap"]) {
3281
+ const val = cs.getPropertyValue(prop);
3282
+ if (val && val !== "0px" && val !== "normal" && val !== "auto") {
3283
+ const entry = spacingMap.get(val) || { count: 0, properties: /* @__PURE__ */ new Set() };
3284
+ entry.count++;
3285
+ entry.properties.add(prop.replace(/-(?:top|right|bottom|left)/, ""));
3286
+ spacingMap.set(val, entry);
3287
+ }
3288
+ }
3289
+ const radius = cs.borderRadius;
3290
+ if (radius && radius !== "0px") {
3291
+ radiusMap.set(radius, (radiusMap.get(radius) || 0) + 1);
3292
+ }
3293
+ const shadow = cs.boxShadow;
3294
+ if (shadow && shadow !== "none") {
3295
+ shadowMap.set(shadow, (shadowMap.get(shadow) || 0) + 1);
3296
+ }
3297
+ }
3298
+ function rgbToHex(rgb) {
3299
+ const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
3300
+ if (!match) return rgb;
3301
+ return "#" + [match[1], match[2], match[3]].map((n) => parseInt(n).toString(16).padStart(2, "0")).join("");
3302
+ }
3303
+ const colors = Array.from(colorMap.entries()).map(([value, data]) => ({
3304
+ value,
3305
+ hex: rgbToHex(value),
3306
+ usageCount: data.count,
3307
+ properties: Array.from(data.properties),
3308
+ sampleSelectors: Array.from(data.selectors)
3309
+ })).sort((a, b) => b.usageCount - a.usageCount).slice(0, 50);
3310
+ const typography = Array.from(typoMap.values()).map((data) => ({
3311
+ ...data.parsed,
3312
+ usageCount: data.count,
3313
+ sampleSelectors: Array.from(data.selectors)
3314
+ })).sort((a, b) => b.usageCount - a.usageCount).slice(0, 30);
3315
+ const spacing = Array.from(spacingMap.entries()).map(([value, data]) => ({
3316
+ value,
3317
+ pixels: parseFloat(value) || 0,
3318
+ usageCount: data.count,
3319
+ properties: Array.from(data.properties)
3320
+ })).sort((a, b) => b.usageCount - a.usageCount).slice(0, 30);
3321
+ const borderRadii = Array.from(radiusMap.entries()).map(([value, count]) => ({ value, usageCount: count })).sort((a, b) => b.usageCount - a.usageCount);
3322
+ const boxShadows = Array.from(shadowMap.entries()).map(([value, count]) => ({ value, usageCount: count })).sort((a, b) => b.usageCount - a.usageCount);
3323
+ const sampleClassNames = [];
3324
+ const classCounts = { tailwind: 0, bem: 0, modules: 0, atomic: 0, vanilla: 0 };
3325
+ document.querySelectorAll("[class]").forEach((el) => {
3326
+ if (sampleClassNames.length < 20) {
3327
+ for (const c of el.classList) sampleClassNames.push(c);
3328
+ }
3329
+ for (const c of el.classList) {
3330
+ if (/^(flex|grid|text-|bg-|p-|m-|w-|h-|gap-|items-|justify-)/.test(c)) classCounts.tailwind++;
3331
+ else if (/^[a-z]+--.+/.test(c) || /__/.test(c)) classCounts.bem++;
3332
+ else if (/^[a-zA-Z]+_[a-zA-Z0-9_]{5,}$/.test(c)) classCounts.modules++;
3333
+ }
3334
+ });
3335
+ let cssArchitecture = "vanilla";
3336
+ const classNamingPatterns = [];
3337
+ if (classCounts.tailwind > 10) {
3338
+ cssArchitecture = "tailwind";
3339
+ classNamingPatterns.push("tailwind utilities");
3340
+ } else if (classCounts.bem > 5) {
3341
+ cssArchitecture = "bem";
3342
+ classNamingPatterns.push("BEM naming");
3343
+ } else if (classCounts.modules > 5) {
3344
+ cssArchitecture = "css-modules";
3345
+ classNamingPatterns.push("CSS Modules");
3346
+ }
3347
+ return {
3348
+ customProperties,
3349
+ colors,
3350
+ typography,
3351
+ spacing,
3352
+ borderRadii,
3353
+ boxShadows,
3354
+ cssArchitecture,
3355
+ classNamingPatterns,
3356
+ sampleClassNames: sampleClassNames.slice(0, 20)
3357
+ };
3358
+ });
3359
+ }
3360
+ async function collectLayoutTree(page, maxDepth = 6) {
3361
+ return page.evaluate((maxD) => {
3362
+ function walkNode(el, depth) {
3363
+ if (depth > maxD) return null;
3364
+ const cs = getComputedStyle(el);
3365
+ if (cs.display === "none") return null;
3366
+ const rect = el.getBoundingClientRect();
3367
+ const tag = el.tagName.toLowerCase();
3368
+ const dataAttributes = {};
3369
+ for (const attr of el.attributes) {
3370
+ if (attr.name.startsWith("data-")) dataAttributes[attr.name] = attr.value;
3371
+ }
3372
+ const children = [];
3373
+ let childCount = 0;
3374
+ for (const child of el.children) {
3375
+ childCount++;
3376
+ if (children.length < 20) {
3377
+ const childNode = walkNode(child, depth + 1);
3378
+ if (childNode) children.push(childNode);
3379
+ }
3380
+ }
3381
+ const node = {
3382
+ tag,
3383
+ classList: Array.from(el.classList),
3384
+ dataAttributes,
3385
+ boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
3386
+ display: cs.display,
3387
+ position: cs.position,
3388
+ children,
3389
+ childCount
3390
+ };
3391
+ if (el.id) node.id = el.id;
3392
+ if (el.getAttribute("role")) node.role = el.getAttribute("role");
3393
+ if (el.getAttribute("aria-label")) node.ariaLabel = el.getAttribute("aria-label");
3394
+ if (cs.display.includes("flex")) {
3395
+ node.flexDirection = cs.flexDirection;
3396
+ node.justifyContent = cs.justifyContent;
3397
+ node.alignItems = cs.alignItems;
3398
+ }
3399
+ if (cs.display.includes("grid")) {
3400
+ node.gridTemplateColumns = cs.gridTemplateColumns;
3401
+ node.gridTemplateRows = cs.gridTemplateRows;
3402
+ node.gap = cs.gap;
3403
+ }
3404
+ if (el.children.length === 0 && el.textContent) {
3405
+ node.textContent = el.textContent.trim().slice(0, 100);
3406
+ }
3407
+ return node;
3408
+ }
3409
+ const body = document.body;
3410
+ const tree = walkNode(body, 0) || {
3411
+ tag: "body",
3412
+ classList: [],
3413
+ dataAttributes: {},
3414
+ boundingRect: { x: 0, y: 0, width: 0, height: 0 },
3415
+ display: "block",
3416
+ position: "static",
3417
+ children: [],
3418
+ childCount: 0
3419
+ };
3420
+ let totalElements = 0;
3421
+ function countDepth(node, d) {
3422
+ totalElements++;
3423
+ let max = d;
3424
+ for (const child of node.children) {
3425
+ max = Math.max(max, countDepth(child, d + 1));
3426
+ }
3427
+ return max;
3428
+ }
3429
+ const maxDepth2 = countDepth(tree, 0);
3430
+ return {
3431
+ viewport: { width: window.innerWidth, height: window.innerHeight },
3432
+ scrollHeight: document.documentElement.scrollHeight,
3433
+ tree,
3434
+ totalElements,
3435
+ maxDepth: maxDepth2
3436
+ };
3437
+ }, maxDepth);
3438
+ }
3439
+ async function collectAccessibility(page) {
3440
+ return page.evaluate(() => {
3441
+ function getSelector(el) {
3442
+ if (el.id) return `#${el.id}`;
3443
+ const tag = el.tagName.toLowerCase();
3444
+ const cls = el.classList[0];
3445
+ return cls ? `${tag}.${cls}` : tag;
3446
+ }
3447
+ const headings = [];
3448
+ document.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((el) => {
3449
+ headings.push({
3450
+ level: parseInt(el.tagName[1]),
3451
+ text: (el.textContent || "").trim().slice(0, 100),
3452
+ selector: getSelector(el)
3453
+ });
3454
+ });
3455
+ const landmarks = [];
3456
+ const landmarkEls = document.querySelectorAll('nav,main,aside,header,footer,section[aria-label],form[aria-label],[role="navigation"],[role="main"],[role="complementary"],[role="banner"],[role="contentinfo"],[role="search"]');
3457
+ landmarkEls.forEach((el) => {
3458
+ const role = el.getAttribute("role") || el.tagName.toLowerCase();
3459
+ const label = el.getAttribute("aria-label") || el.getAttribute("aria-labelledby") || void 0;
3460
+ landmarks.push({ role, label, selector: getSelector(el) });
3461
+ });
3462
+ const formFields = [];
3463
+ document.querySelectorAll("input,select,textarea").forEach((el) => {
3464
+ const input = el;
3465
+ const id = input.id;
3466
+ const label = id ? document.querySelector(`label[for="${id}"]`)?.textContent?.trim() : void 0;
3467
+ formFields.push({
3468
+ tag: el.tagName.toLowerCase(),
3469
+ type: input.type || void 0,
3470
+ name: input.name || void 0,
3471
+ label: label || input.getAttribute("aria-label") || input.placeholder || void 0,
3472
+ required: input.required,
3473
+ selector: getSelector(el)
3474
+ });
3475
+ });
3476
+ const links = [];
3477
+ document.querySelectorAll("a[href]").forEach((el) => {
3478
+ if (links.length >= 50) return;
3479
+ const a = el;
3480
+ links.push({
3481
+ tag: "a",
3482
+ text: (a.textContent || "").trim().slice(0, 80),
3483
+ href: a.getAttribute("href") || "",
3484
+ selector: getSelector(el)
3485
+ });
3486
+ });
3487
+ const buttons = [];
3488
+ document.querySelectorAll('button,[role="button"],input[type="submit"],input[type="button"]').forEach((el) => {
3489
+ buttons.push({
3490
+ tag: el.tagName.toLowerCase(),
3491
+ text: (el.textContent || el.value || "").trim().slice(0, 80),
3492
+ role: el.getAttribute("role") || void 0,
3493
+ selector: getSelector(el)
3494
+ });
3495
+ });
3496
+ const images = [];
3497
+ document.querySelectorAll("img").forEach((el) => {
3498
+ const img = el;
3499
+ images.push({
3500
+ src: img.src,
3501
+ alt: img.alt,
3502
+ hasAlt: img.hasAttribute("alt") && img.alt.length > 0,
3503
+ selector: getSelector(el)
3504
+ });
3505
+ });
3506
+ const issues = [];
3507
+ let prevLevel = 0;
3508
+ for (const h of headings) {
3509
+ if (h.level > prevLevel + 1 && prevLevel > 0) {
3510
+ issues.push(`Heading level skip: h${prevLevel} \u2192 h${h.level}`);
3511
+ }
3512
+ prevLevel = h.level;
3513
+ }
3514
+ if (!landmarks.some((l) => l.role === "main")) {
3515
+ issues.push("No <main> landmark found");
3516
+ }
3517
+ const missingAlt = images.filter((i) => !i.hasAlt);
3518
+ if (missingAlt.length > 0) {
3519
+ issues.push(`${missingAlt.length} image(s) missing alt text`);
3520
+ }
3521
+ return { headings, landmarks, formFields, links, buttons, images, issues };
3522
+ });
3523
+ }
3524
+ async function collectFonts(page) {
3525
+ return page.evaluate(() => {
3526
+ const fontFaces = [];
3527
+ try {
3528
+ for (const face of document.fonts) {
3529
+ fontFaces.push({
3530
+ family: face.family.replace(/"/g, ""),
3531
+ weight: face.weight,
3532
+ style: face.style,
3533
+ src: "",
3534
+ // Not accessible from API
3535
+ display: face.display || void 0
3536
+ });
3537
+ }
3538
+ } catch {
3539
+ }
3540
+ try {
3541
+ for (const sheet of document.styleSheets) {
3542
+ try {
3543
+ for (const rule of sheet.cssRules) {
3544
+ if (rule instanceof CSSFontFaceRule) {
3545
+ const style = rule.style;
3546
+ const family = style.getPropertyValue("font-family").replace(/["']/g, "");
3547
+ if (family && !fontFaces.some((f) => f.family === family && f.weight === style.getPropertyValue("font-weight"))) {
3548
+ fontFaces.push({
3549
+ family,
3550
+ weight: style.getPropertyValue("font-weight") || "400",
3551
+ style: style.getPropertyValue("font-style") || "normal",
3552
+ src: style.getPropertyValue("src") || "",
3553
+ display: style.getPropertyValue("font-display") || void 0
3554
+ });
3555
+ }
3556
+ }
3557
+ }
3558
+ } catch {
3559
+ }
3560
+ }
3561
+ } catch {
3562
+ }
3563
+ const fontUsageMap = /* @__PURE__ */ new Map();
3564
+ const elements = document.querySelectorAll("body *");
3565
+ const limit = Math.min(elements.length, 200);
3566
+ for (let i = 0; i < limit; i++) {
3567
+ const el = elements[i];
3568
+ const elRect = el.getBoundingClientRect();
3569
+ if (elRect.width === 0 && elRect.height === 0) continue;
3570
+ const cs = getComputedStyle(el);
3571
+ const key = `${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}`;
3572
+ const entry = fontUsageMap.get(key) || { count: 0, selectors: /* @__PURE__ */ new Set() };
3573
+ entry.count++;
3574
+ const cls = el.getAttribute("class") || "";
3575
+ const selector = el.tagName.toLowerCase() + (cls ? "." + cls.split(" ")[0] : "");
3576
+ if (entry.selectors.size < 3) entry.selectors.add(selector);
3577
+ fontUsageMap.set(key, entry);
3578
+ }
3579
+ const fontsUsed = Array.from(fontUsageMap.entries()).map(([key, data]) => {
3580
+ const [family, weight, style] = key.split("|");
3581
+ return {
3582
+ family: family.split(",")[0].trim().replace(/["']/g, ""),
3583
+ weight,
3584
+ style,
3585
+ usageCount: data.count,
3586
+ sampleSelectors: Array.from(data.selectors)
3587
+ };
3588
+ }).sort((a, b) => b.usageCount - a.usageCount);
3589
+ const strategies = [];
3590
+ const hasPreload = document.querySelector('link[rel="preload"][as="font"]');
3591
+ if (hasPreload) strategies.push("preloaded");
3592
+ if (fontFaces.some((f) => f.display === "swap")) strategies.push("font-display: swap");
3593
+ if (fontFaces.some((f) => f.src.includes("woff2"))) strategies.push("woff2");
3594
+ const loadingStrategy = strategies.length > 0 ? strategies.join(" + ") : "default";
3595
+ return { fontFaces, fontsUsed, iconFonts: [], loadingStrategy };
3596
+ });
3597
+ }
3598
+ async function collectAssets(page) {
3599
+ return page.evaluate(() => {
3600
+ function getSelector(el) {
3601
+ if (el.id) return `#${el.id}`;
3602
+ const tag = el.tagName.toLowerCase();
3603
+ const cls = el.classList[0];
3604
+ return cls ? `${tag}.${cls}` : tag;
3605
+ }
3606
+ const images = [];
3607
+ document.querySelectorAll("img").forEach((el) => {
3608
+ const img = el;
3609
+ const src = img.src;
3610
+ const ext = src.split(".").pop()?.split("?")[0]?.toLowerCase() || "";
3611
+ const format = { webp: "webp", png: "png", jpg: "jpeg", jpeg: "jpeg", gif: "gif", svg: "svg", avif: "avif" }[ext] || ext;
3612
+ images.push({
3613
+ src,
3614
+ alt: img.alt,
3615
+ width: img.width,
3616
+ height: img.height,
3617
+ naturalWidth: img.naturalWidth,
3618
+ naturalHeight: img.naturalHeight,
3619
+ format,
3620
+ selector: getSelector(el)
3621
+ });
3622
+ });
3623
+ const inlineSVGs = [];
3624
+ document.querySelectorAll("svg").forEach((el) => {
3625
+ const svg = el;
3626
+ if (svg.closest("symbol") || svg.closest('[style*="display: none"]')) return;
3627
+ const rect = svg.getBoundingClientRect();
3628
+ if (rect.width === 0 && rect.height === 0) return;
3629
+ inlineSVGs.push({
3630
+ selector: getSelector(el),
3631
+ viewBox: svg.getAttribute("viewBox") || "",
3632
+ width: Math.round(rect.width),
3633
+ height: Math.round(rect.height),
3634
+ source: svg.outerHTML.slice(0, 500)
3635
+ });
3636
+ });
3637
+ const svgSprites = [];
3638
+ document.querySelectorAll("svg symbol[id]").forEach((el) => {
3639
+ const symbol = el;
3640
+ const id = symbol.id;
3641
+ const refs = [];
3642
+ document.querySelectorAll(`use[href="#${id}"],use[xlink\\:href="#${id}"]`).forEach((use) => {
3643
+ refs.push(getSelector(use.closest("svg") || use));
3644
+ });
3645
+ svgSprites.push({
3646
+ id,
3647
+ viewBox: symbol.getAttribute("viewBox") || "",
3648
+ paths: symbol.innerHTML.slice(0, 200),
3649
+ referencedBy: refs
3650
+ });
3651
+ });
3652
+ const backgroundSprites = [];
3653
+ const bgSpriteMap = /* @__PURE__ */ new Map();
3654
+ document.querySelectorAll("*").forEach((el) => {
3655
+ const cs = getComputedStyle(el);
3656
+ const bgImage = cs.backgroundImage;
3657
+ if (!bgImage || bgImage === "none" || !bgImage.startsWith("url(")) return;
3658
+ const bgPos = cs.backgroundPosition;
3659
+ const bgSize = cs.backgroundSize;
3660
+ if (bgSize === "cover" || bgSize === "contain" || bgSize === "auto") return;
3661
+ const urlMatch = bgImage.match(/url\(["']?(.+?)["']?\)/);
3662
+ if (!urlMatch) return;
3663
+ const url = urlMatch[1];
3664
+ const rect = el.getBoundingClientRect();
3665
+ const posMatch = bgPos.match(/([-\d.]+)px\s+([-\d.]+)px/);
3666
+ if (!posMatch) return;
3667
+ const frames = bgSpriteMap.get(url) || [];
3668
+ frames.push({
3669
+ selector: getSelector(el),
3670
+ cropX: Math.abs(parseFloat(posMatch[1])),
3671
+ cropY: Math.abs(parseFloat(posMatch[2])),
3672
+ cropWidth: Math.round(rect.width),
3673
+ cropHeight: Math.round(rect.height)
3674
+ });
3675
+ bgSpriteMap.set(url, frames);
3676
+ });
3677
+ for (const [url, frames] of bgSpriteMap.entries()) {
3678
+ if (frames.length >= 2) {
3679
+ backgroundSprites.push({ sheetUrl: url, sheetWidth: 0, sheetHeight: 0, frames });
3680
+ }
3681
+ }
3682
+ const totalAssets = images.length + inlineSVGs.length + svgSprites.length + backgroundSprites.reduce((s, b) => s + b.frames.length, 0);
3683
+ return {
3684
+ images,
3685
+ inlineSVGs,
3686
+ svgSprites,
3687
+ backgroundSprites,
3688
+ maskSprites: [],
3689
+ iconFonts: [],
3690
+ totalAssets
3691
+ };
3692
+ });
3693
+ }
3694
+ async function collectComputedStyles(page, selector, propertyFilter) {
3695
+ return page.evaluate(
3696
+ ({ sel, propFilter }) => {
3697
+ const elements = document.querySelectorAll(sel);
3698
+ if (elements.length === 0) {
3699
+ return { selector: sel, propertyFilter: propFilter, entries: [] };
3700
+ }
3701
+ const DEFAULT_PROPS = [
3702
+ "color",
3703
+ "background-color",
3704
+ "border-color",
3705
+ "font-family",
3706
+ "font-size",
3707
+ "font-weight",
3708
+ "font-style",
3709
+ "line-height",
3710
+ "letter-spacing",
3711
+ "text-align",
3712
+ "text-transform",
3713
+ "text-decoration",
3714
+ "display",
3715
+ "position",
3716
+ "top",
3717
+ "right",
3718
+ "bottom",
3719
+ "left",
3720
+ "width",
3721
+ "height",
3722
+ "min-width",
3723
+ "max-width",
3724
+ "min-height",
3725
+ "max-height",
3726
+ "margin-top",
3727
+ "margin-right",
3728
+ "margin-bottom",
3729
+ "margin-left",
3730
+ "padding-top",
3731
+ "padding-right",
3732
+ "padding-bottom",
3733
+ "padding-left",
3734
+ "gap",
3735
+ "flex-direction",
3736
+ "justify-content",
3737
+ "align-items",
3738
+ "flex-wrap",
3739
+ "grid-template-columns",
3740
+ "grid-template-rows",
3741
+ "border-width",
3742
+ "border-style",
3743
+ "border-radius",
3744
+ "box-shadow",
3745
+ "text-shadow",
3746
+ "opacity",
3747
+ "overflow",
3748
+ "z-index",
3749
+ "transform",
3750
+ "transition",
3751
+ "background-image",
3752
+ "background-size",
3753
+ "cursor",
3754
+ "pointer-events"
3755
+ ];
3756
+ const propsToCapture = propFilter && propFilter.length > 0 ? propFilter : DEFAULT_PROPS;
3757
+ const allStyles = [];
3758
+ const limit = Math.min(elements.length, 50);
3759
+ for (let i = 0; i < limit; i++) {
3760
+ const cs = getComputedStyle(elements[i]);
3761
+ const styles = {};
3762
+ for (const prop of propsToCapture) {
3763
+ const val = cs.getPropertyValue(prop);
3764
+ if (val) styles[prop] = val;
3765
+ }
3766
+ allStyles.push(styles);
3767
+ }
3768
+ const baseStyles = allStyles[0] || {};
3769
+ const variations = [];
3770
+ if (allStyles.length > 1) {
3771
+ for (const prop of propsToCapture) {
3772
+ const valueCounts = /* @__PURE__ */ new Map();
3773
+ for (const s of allStyles) {
3774
+ const val = s[prop] || "";
3775
+ valueCounts.set(val, (valueCounts.get(val) || 0) + 1);
3776
+ }
3777
+ if (valueCounts.size > 1) {
3778
+ variations.push({
3779
+ property: prop,
3780
+ values: Array.from(valueCounts.entries()).map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count)
3781
+ });
3782
+ }
3783
+ }
3784
+ }
3785
+ return {
3786
+ selector: sel,
3787
+ propertyFilter: propFilter,
3788
+ entries: [{
3789
+ selector: sel,
3790
+ matchCount: elements.length,
3791
+ styles: baseStyles,
3792
+ variations
3793
+ }]
3794
+ };
3795
+ },
3796
+ { sel: selector, propFilter: propertyFilter }
3797
+ );
3798
+ }
3799
+ async function collectElementSnapshot(page, selector, maxDepth = 5) {
3800
+ return page.evaluate(
3801
+ ({ sel, maxD }) => {
3802
+ const rootEl = document.querySelector(sel);
3803
+ if (!rootEl) return null;
3804
+ const KEY_STYLES = [
3805
+ "display",
3806
+ "position",
3807
+ "color",
3808
+ "background-color",
3809
+ "font-family",
3810
+ "font-size",
3811
+ "font-weight",
3812
+ "line-height",
3813
+ "width",
3814
+ "height",
3815
+ "margin",
3816
+ "padding",
3817
+ "border",
3818
+ "flex-direction",
3819
+ "justify-content",
3820
+ "align-items",
3821
+ "gap",
3822
+ "grid-template-columns",
3823
+ "grid-template-rows",
3824
+ "border-radius",
3825
+ "box-shadow",
3826
+ "opacity",
3827
+ "overflow",
3828
+ "z-index"
3829
+ ];
3830
+ let totalNodes = 0;
3831
+ function walk(el, depth) {
3832
+ totalNodes++;
3833
+ const rect = el.getBoundingClientRect();
3834
+ const cs = getComputedStyle(el);
3835
+ const attributes = {};
3836
+ for (const attr of el.attributes) {
3837
+ if (!["class", "id", "style"].includes(attr.name)) {
3838
+ attributes[attr.name] = attr.value.slice(0, 200);
3839
+ }
3840
+ }
3841
+ const computedStyles = {};
3842
+ for (const prop of KEY_STYLES) {
3843
+ const val = cs.getPropertyValue(prop);
3844
+ if (val) computedStyles[prop] = val;
3845
+ }
3846
+ const children = [];
3847
+ if (depth < maxD) {
3848
+ const childLimit = Math.min(el.children.length, 30);
3849
+ for (let i = 0; i < childLimit; i++) {
3850
+ children.push(walk(el.children[i], depth + 1));
3851
+ }
3852
+ }
3853
+ const node = {
3854
+ tag: el.tagName.toLowerCase(),
3855
+ classList: Array.from(el.classList),
3856
+ attributes,
3857
+ boundingRect: {
3858
+ x: Math.round(rect.x),
3859
+ y: Math.round(rect.y),
3860
+ width: Math.round(rect.width),
3861
+ height: Math.round(rect.height)
3862
+ },
3863
+ computedStyles,
3864
+ children
3865
+ };
3866
+ if (el.id) node.id = el.id;
3867
+ if (el.children.length === 0 && el.textContent) {
3868
+ node.textContent = el.textContent.trim().slice(0, 200);
3869
+ }
3870
+ return node;
3871
+ }
3872
+ const root = walk(rootEl, 0);
3873
+ return {
3874
+ selector: sel,
3875
+ depth: maxD,
3876
+ totalNodes,
3877
+ root
3878
+ };
3879
+ },
3880
+ { sel: selector, maxD: maxDepth }
3881
+ );
3882
+ }
3883
+
3884
+ // src/scanner/event-builder.ts
3885
+ function makeEventId() {
3886
+ return `evt-scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3887
+ }
3888
+ function buildReconEvents(url, title, sessionId, techResults, tokens, layout, a11y, fonts, assets, viewport, meta, scriptSrcs, stylesheetHrefs) {
3889
+ const timestamp = Date.now();
3890
+ const events = [];
3891
+ const framework = techResults.find((t) => t.categories.some((c) => c.id === 12));
3892
+ const metaFramework = techResults.find(
3893
+ (t) => t.categories.some((c) => c.id === 18 || c.id === 57) && t.name !== framework?.name
3894
+ );
3895
+ const uiLib = techResults.find((t) => t.categories.some((c) => c.id === 66));
3896
+ const buildTool = techResults.find(
3897
+ (t) => t.categories.some((c) => c.id === 19) || t.name.match(/webpack|vite|parcel|turbopack|esbuild|rollup/i)
3898
+ );
3899
+ const hosting = techResults.find(
3900
+ (t) => t.categories.some((c) => c.id === 62 || c.id === 31) || t.name.match(/vercel|netlify|cloudflare|aws|heroku/i)
3901
+ );
3902
+ function toDetection(result) {
3903
+ if (!result) return { name: "unknown", confidence: "low", evidence: [] };
3904
+ return {
3905
+ name: result.name.toLowerCase(),
3906
+ confidence: result.confidence >= 75 ? "high" : result.confidence >= 40 ? "medium" : "low",
3907
+ version: result.version || void 0,
3908
+ evidence: [`Detected by scanner (confidence: ${result.confidence}%)`]
3909
+ };
3910
+ }
3911
+ const metadataEvent = {
3912
+ eventId: makeEventId(),
3913
+ sessionId,
3914
+ timestamp,
3915
+ eventType: "recon_metadata",
3916
+ url,
3917
+ title,
3918
+ viewport,
3919
+ documentLang: "",
3920
+ // Not critical for this use case
3921
+ metaTags: meta,
3922
+ techStack: {
3923
+ framework: toDetection(framework),
3924
+ metaFramework: metaFramework ? toDetection(metaFramework) : void 0,
3925
+ uiLibrary: uiLib ? toDetection(uiLib) : void 0,
3926
+ buildTool: buildTool ? toDetection(buildTool) : void 0,
3927
+ hosting: hosting ? toDetection(hosting) : void 0,
3928
+ additional: techResults.filter((t) => t !== framework && t !== metaFramework && t !== uiLib && t !== buildTool && t !== hosting).slice(0, 20).map((t) => ({
3929
+ name: t.name,
3930
+ confidence: t.confidence >= 75 ? "high" : t.confidence >= 40 ? "medium" : "low",
3931
+ version: t.version || void 0,
3932
+ evidence: [`${t.categories.map((c) => c.name).join(", ")}`]
3933
+ }))
3934
+ },
3935
+ externalStylesheets: stylesheetHrefs.map((href) => ({ href, crossOrigin: !href.startsWith(url) })),
3936
+ externalScripts: scriptSrcs.map((src) => ({ src, async: false, defer: false, type: "text/javascript" })),
3937
+ preloads: []
3938
+ };
3939
+ events.push(metadataEvent);
3940
+ const designTokensEvent = {
3941
+ eventId: makeEventId(),
3942
+ sessionId,
3943
+ timestamp,
3944
+ eventType: "recon_design_tokens",
3945
+ url,
3946
+ ...tokens
3947
+ };
3948
+ events.push(designTokensEvent);
3949
+ const layoutEvent = {
3950
+ eventId: makeEventId(),
3951
+ sessionId,
3952
+ timestamp,
3953
+ eventType: "recon_layout_tree",
3954
+ url,
3955
+ viewport: layout.viewport,
3956
+ scrollHeight: layout.scrollHeight,
3957
+ tree: layout.tree,
3958
+ totalElements: layout.totalElements,
3959
+ maxDepth: layout.maxDepth
3960
+ };
3961
+ events.push(layoutEvent);
3962
+ const a11yEvent = {
3963
+ eventId: makeEventId(),
3964
+ sessionId,
3965
+ timestamp,
3966
+ eventType: "recon_accessibility",
3967
+ url,
3968
+ ...a11y
3969
+ };
3970
+ events.push(a11yEvent);
3971
+ const fontsEvent = {
3972
+ eventId: makeEventId(),
3973
+ sessionId,
3974
+ timestamp,
3975
+ eventType: "recon_fonts",
3976
+ url,
3977
+ ...fonts
3978
+ };
3979
+ events.push(fontsEvent);
3980
+ const assetsEvent = {
3981
+ eventId: makeEventId(),
3982
+ sessionId,
3983
+ timestamp,
3984
+ eventType: "recon_asset_inventory",
3985
+ url,
3986
+ ...assets
3987
+ };
3988
+ events.push(assetsEvent);
3989
+ return events;
3990
+ }
3991
+
3992
+ // src/scanner/index.ts
3993
+ var PlaywrightScanner = class _PlaywrightScanner {
3994
+ db = null;
3995
+ jsGlobalPaths = [];
3996
+ domSelectors = [];
3997
+ browser = null;
3998
+ idleTimer = null;
3999
+ static IDLE_TIMEOUT = 6e4;
4000
+ // Close browser after 60s idle
4001
+ lastScannedUrl = null;
4002
+ /**
4003
+ * Lazily load the technology database.
4004
+ */
4005
+ ensureDb() {
4006
+ if (this.db) return this.db;
4007
+ const __dirname = dirname(fileURLToPath(import.meta.url));
4008
+ const possiblePaths = [
4009
+ resolve(__dirname, "../../../node_modules/@runtimescope/extension/src/data"),
4010
+ resolve(__dirname, "../../extension/src/data")
4011
+ ];
4012
+ let techData = null;
4013
+ let catData = null;
4014
+ for (const basePath of possiblePaths) {
4015
+ try {
4016
+ techData = JSON.parse(readFileSync(resolve(basePath, "technologies.json"), "utf-8"));
4017
+ catData = JSON.parse(readFileSync(resolve(basePath, "categories.json"), "utf-8"));
4018
+ break;
4019
+ } catch {
4020
+ continue;
4021
+ }
4022
+ }
4023
+ if (!techData || !catData) {
4024
+ throw new Error("Could not load technology database. Ensure @runtimescope/extension is built.");
4025
+ }
4026
+ this.db = new TechnologyDatabase(techData, catData);
4027
+ const allTechs = this.db.getAll();
4028
+ this.jsGlobalPaths = extractJsGlobalPaths(allTechs);
4029
+ this.domSelectors = extractDomSelectors(allTechs);
4030
+ console.error(`[RuntimeScope] Scanner loaded: ${this.db.size} technologies, ${this.jsGlobalPaths.length} JS paths, ${this.domSelectors.length} DOM selectors`);
4031
+ return this.db;
4032
+ }
4033
+ /**
4034
+ * Lazily launch or reuse a Chromium browser.
4035
+ */
4036
+ async ensureBrowser() {
4037
+ if (this.idleTimer) {
4038
+ clearTimeout(this.idleTimer);
4039
+ this.idleTimer = null;
4040
+ }
4041
+ const pw = await import("playwright");
4042
+ if (!this.browser || !this.browser.isConnected()) {
4043
+ this.browser = await pw.chromium.launch({ headless: true });
4044
+ console.error("[RuntimeScope] Scanner: Chromium launched");
4045
+ }
4046
+ this.idleTimer = setTimeout(() => {
4047
+ this.shutdown().catch(() => {
4048
+ });
4049
+ }, _PlaywrightScanner.IDLE_TIMEOUT);
4050
+ return { chromium: pw.chromium, browser: this.browser };
4051
+ }
4052
+ /**
4053
+ * Scan a website: collect all signals, detect tech stack, build recon events.
4054
+ */
4055
+ async scan(url, options = {}) {
4056
+ const startTime = Date.now();
4057
+ const {
4058
+ viewportWidth = 1280,
4059
+ viewportHeight = 720,
4060
+ waitFor = "networkidle",
4061
+ timeout = 6e4
4062
+ } = options;
4063
+ const db = this.ensureDb();
4064
+ const { browser } = await this.ensureBrowser();
4065
+ const br = browser;
4066
+ const context = await br.newContext({
4067
+ viewport: { width: viewportWidth, height: viewportHeight },
4068
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
4069
+ });
4070
+ const page = await context.newPage();
4071
+ try {
4072
+ let mainResponse = null;
4073
+ page.on("response", (response) => {
4074
+ if (!mainResponse && response.request().resourceType() === "document") {
4075
+ mainResponse = response;
4076
+ }
4077
+ });
4078
+ await page.goto(url, {
4079
+ waitUntil: waitFor,
4080
+ timeout
4081
+ });
4082
+ const title = await page.title();
4083
+ const sessionId = `scan-${Date.now()}`;
4084
+ const stylesheetHrefs = await page.evaluate(
4085
+ () => Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((el) => el.getAttribute("href") || "")
4086
+ );
4087
+ const [signals, tokens, layout, a11y, fonts, assets] = await Promise.all([
4088
+ collectDetectionSignals(page, mainResponse, this.jsGlobalPaths, this.domSelectors),
4089
+ collectDesignTokens(page),
4090
+ collectLayoutTree(page),
4091
+ collectAccessibility(page),
4092
+ collectFonts(page),
4093
+ collectAssets(page)
4094
+ ]);
4095
+ const techStack = detect(signals, db);
4096
+ const events = buildReconEvents(
4097
+ url,
4098
+ title,
4099
+ sessionId,
4100
+ techStack,
4101
+ tokens,
4102
+ layout,
4103
+ a11y,
4104
+ fonts,
4105
+ assets,
4106
+ { width: viewportWidth, height: viewportHeight },
4107
+ signals.meta || {},
4108
+ signals.scriptSrc || [],
4109
+ stylesheetHrefs
4110
+ );
4111
+ const topTechs = techStack.slice(0, 10).map((t) => `${t.name}${t.version ? " " + t.version : ""} (${t.confidence}%)`);
4112
+ const summaryParts = [
4113
+ `Scanned: ${title || url}`,
4114
+ `Tech stack: ${topTechs.join(", ") || "none detected"}`,
4115
+ `Design: ${tokens.customProperties.length} CSS vars, ${tokens.colors.length} colors, ${tokens.typography.length} type combos`,
4116
+ `Layout: ${layout.totalElements} elements, depth ${layout.maxDepth}`,
4117
+ `Fonts: ${fonts.fontFaces.length} faces, ${fonts.fontsUsed.length} used`,
4118
+ `Assets: ${assets.images.length} images, ${assets.inlineSVGs.length} SVGs, ${assets.totalAssets} total`,
4119
+ `Accessibility: ${a11y.headings.length} headings, ${a11y.landmarks.length} landmarks, ${a11y.issues.length} issues`
4120
+ ];
4121
+ const scanDurationMs = Date.now() - startTime;
4122
+ this.lastScannedUrl = page.url();
4123
+ return {
4124
+ url: page.url(),
4125
+ title,
4126
+ techStack,
4127
+ events,
4128
+ summary: summaryParts.join(". ") + `. Scan took ${scanDurationMs}ms.`,
4129
+ scanDurationMs
4130
+ };
4131
+ } finally {
4132
+ await context.close();
4133
+ }
4134
+ }
4135
+ /**
4136
+ * Get the last scanned URL (so tools know a scan was performed).
4137
+ */
4138
+ getLastScannedUrl() {
4139
+ return this.lastScannedUrl;
4140
+ }
4141
+ /**
4142
+ * On-demand: query computed styles for a selector on a previously scanned URL.
4143
+ * Opens a fresh page, navigates, collects, closes.
4144
+ */
4145
+ async queryComputedStyles(url, selector, propertyFilter) {
4146
+ const { browser } = await this.ensureBrowser();
4147
+ const br = browser;
4148
+ const context = await br.newContext({
4149
+ viewport: { width: 1280, height: 720 }
4150
+ });
4151
+ const page = await context.newPage();
4152
+ try {
4153
+ await page.goto(url, { waitUntil: "networkidle", timeout: 6e4 });
4154
+ return await collectComputedStyles(page, selector, propertyFilter);
4155
+ } finally {
4156
+ await context.close();
4157
+ }
4158
+ }
4159
+ /**
4160
+ * On-demand: query element snapshot for a selector on a previously scanned URL.
4161
+ * Opens a fresh page, navigates, collects, closes.
4162
+ */
4163
+ async queryElementSnapshot(url, selector, depth = 5) {
4164
+ const { browser } = await this.ensureBrowser();
4165
+ const br = browser;
4166
+ const context = await br.newContext({
4167
+ viewport: { width: 1280, height: 720 }
4168
+ });
4169
+ const page = await context.newPage();
4170
+ try {
4171
+ await page.goto(url, { waitUntil: "networkidle", timeout: 6e4 });
4172
+ return await collectElementSnapshot(page, selector, depth);
4173
+ } finally {
4174
+ await context.close();
4175
+ }
4176
+ }
4177
+ /**
4178
+ * Shutdown: close browser if open.
4179
+ */
4180
+ async shutdown() {
4181
+ if (this.idleTimer) {
4182
+ clearTimeout(this.idleTimer);
4183
+ this.idleTimer = null;
4184
+ }
4185
+ if (this.browser) {
4186
+ try {
4187
+ await this.browser.close();
4188
+ } catch {
4189
+ }
4190
+ this.browser = null;
4191
+ console.error("[RuntimeScope] Scanner: Chromium closed");
4192
+ }
4193
+ }
4194
+ };
4195
+
4196
+ // src/tools/scanner.ts
4197
+ import { z as z25 } from "zod";
4198
+ var COLLECTOR_PORT = process.env.RUNTIMESCOPE_PORT ?? "9090";
4199
+ var HTTP_PORT = process.env.RUNTIMESCOPE_HTTP_PORT ?? "9091";
4200
+ function registerScannerTools(server, store, scanner) {
4201
+ server.tool(
4202
+ "get_sdk_snippet",
4203
+ "Generate a ready-to-paste code snippet to connect any web application to RuntimeScope for live runtime monitoring. Works with ANY tech stack \u2014 React, Vue, Angular, Svelte, plain HTML, Flask/Django templates, Rails ERB, PHP, WordPress, etc. Returns the appropriate installation method based on the project type.",
4204
+ {
4205
+ app_name: z25.string().optional().default("my-app").describe('Name for the app in RuntimeScope (e.g., "echo-frontend", "dashboard")'),
4206
+ framework: z25.enum(["html", "react", "vue", "angular", "svelte", "nextjs", "nuxt", "flask", "django", "rails", "php", "wordpress", "other"]).optional().default("html").describe('The framework/tech stack of the project. Use "html" for any plain HTML or server-rendered pages.')
4207
+ },
4208
+ async ({ app_name, framework }) => {
4209
+ const scriptTagSnippet = `<!-- RuntimeScope \u2014 paste before </body> -->
4210
+ <script src="http://localhost:${HTTP_PORT}/runtimescope.js"></script>
4211
+ <script>
4212
+ RuntimeScope.init({
4213
+ appName: '${app_name}',
4214
+ endpoint: 'ws://localhost:${COLLECTOR_PORT}',
4215
+ });
4216
+ </script>`;
4217
+ const npmSnippet = `// npm install @runtimescope/sdk
4218
+ import { RuntimeScope } from '@runtimescope/sdk';
4219
+
4220
+ RuntimeScope.init({
4221
+ appName: '${app_name}',
4222
+ endpoint: 'ws://localhost:${COLLECTOR_PORT}',
4223
+ });`;
4224
+ const usesNpm = ["react", "vue", "angular", "svelte", "nextjs", "nuxt"].includes(framework);
4225
+ const primarySnippet = usesNpm ? npmSnippet : scriptTagSnippet;
4226
+ const placementHints = {
4227
+ html: "Paste the <script> tags before </body> in your HTML file(s).",
4228
+ react: "Add the import to your entry file (src/index.tsx or src/main.tsx), before ReactDOM.render/createRoot.",
4229
+ vue: "Add the import to your entry file (src/main.ts), before createApp().",
4230
+ angular: "Add the import to your main.ts, before bootstrapApplication().",
4231
+ svelte: "Add the import to your entry file (src/main.ts), before new App().",
4232
+ nextjs: "Add the import to your app/layout.tsx or pages/_app.tsx. For App Router, use a client component wrapper.",
4233
+ nuxt: "Create a plugin file (plugins/runtimescope.client.ts) with the init call.",
4234
+ flask: "Add the <script> tags to your base template (templates/base.html) before </body>.",
4235
+ django: "Add the <script> tags to your base template (templates/base.html) before </body>.",
4236
+ rails: "Add the <script> tags to your application layout (app/views/layouts/application.html.erb) before </body>.",
4237
+ php: "Add the <script> tags to your layout/footer file before </body>.",
4238
+ wordpress: "Add the <script> tags to your theme's footer.php before </body>, or use a custom HTML plugin.",
4239
+ other: "Add the <script> tags to your HTML template before </body>. Works in any HTML page."
4240
+ };
4241
+ const response = {
4242
+ summary: `SDK snippet for ${framework} project "${app_name}". ${usesNpm ? "Uses npm import." : "Uses <script> tag \u2014 no build system required."}`,
4243
+ data: {
4244
+ snippet: primarySnippet,
4245
+ placement: placementHints[framework] || placementHints.other,
4246
+ alternativeSnippet: usesNpm ? scriptTagSnippet : npmSnippet,
4247
+ alternativeNote: usesNpm ? "If you prefer, you can also use a <script> tag instead of npm:" : "If the project uses npm/Node.js, you can also install via:",
4248
+ requirements: [
4249
+ "RuntimeScope MCP server must be running (it starts automatically with Claude Code)",
4250
+ `SDK bundle served at http://localhost:${HTTP_PORT}/runtimescope.js`,
4251
+ `WebSocket collector at ws://localhost:${COLLECTOR_PORT}`
4252
+ ],
4253
+ whatItCaptures: [
4254
+ "Network requests (fetch/XHR) with timing and headers",
4255
+ "Console logs, warnings, and errors with stack traces",
4256
+ "React/Vue/Svelte component renders (if applicable)",
4257
+ "State store changes (Redux, Zustand, Pinia)",
4258
+ "Web Vitals (LCP, FCP, CLS, TTFB, INP)",
4259
+ "Unhandled errors and promise rejections"
4260
+ ]
4261
+ },
4262
+ issues: [],
4263
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
4264
+ };
4265
+ return {
4266
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4267
+ };
4268
+ }
4269
+ );
4270
+ server.tool(
4271
+ "scan_website",
4272
+ "Visit a website with a headless browser and extract comprehensive data: tech stack (7,221 technologies), design tokens (colors, typography, spacing, CSS variables), layout tree (DOM with bounding rects, flex/grid), accessibility structure, fonts, and asset inventory (images, SVGs, sprites). After scanning, all recon tools (get_design_tokens, get_layout_tree, get_font_info, etc.) will return data from the scanned page. This is the primary way to analyze any website.",
4273
+ {
4274
+ url: z25.string().describe('The full URL to scan (e.g., "https://stripe.com")'),
4275
+ viewport_width: z25.number().optional().default(1280).describe("Viewport width in pixels (default: 1280)"),
4276
+ viewport_height: z25.number().optional().default(720).describe("Viewport height in pixels (default: 720)"),
4277
+ wait_for: z25.enum(["load", "networkidle", "domcontentloaded"]).optional().default("networkidle").describe("Wait condition before scanning (default: networkidle)")
4278
+ },
4279
+ async ({ url, viewport_width, viewport_height, wait_for }) => {
4280
+ try {
4281
+ const result = await scanner.scan(url, {
4282
+ viewportWidth: viewport_width,
4283
+ viewportHeight: viewport_height,
4284
+ waitFor: wait_for
4285
+ });
4286
+ for (const event of result.events) {
4287
+ store.addEvent(event);
4288
+ }
4289
+ const topTech = result.techStack.slice(0, 15).map((t) => ({
4290
+ name: t.name,
4291
+ version: t.version || void 0,
4292
+ confidence: t.confidence,
4293
+ categories: t.categories.map((c) => c.name)
4294
+ }));
4295
+ const issues = [];
4296
+ if (result.techStack.length === 0) {
4297
+ issues.push("No technologies detected \u2014 the page may use server-rendered HTML with no identifiable framework.");
4298
+ }
4299
+ const response = {
4300
+ summary: result.summary,
4301
+ data: {
4302
+ url: result.url,
4303
+ title: result.title,
4304
+ techStack: topTech,
4305
+ totalTechnologiesDetected: result.techStack.length,
4306
+ eventsStored: result.events.length,
4307
+ availableTools: [
4308
+ "get_page_metadata \u2014 tech stack details",
4309
+ "get_design_tokens \u2014 colors, typography, spacing, CSS variables",
4310
+ "get_layout_tree \u2014 DOM structure with layout info",
4311
+ "get_font_info \u2014 font faces and usage",
4312
+ "get_accessibility_tree \u2014 headings, landmarks, forms",
4313
+ "get_asset_inventory \u2014 images, SVGs, sprites",
4314
+ "get_computed_styles \u2014 CSS values for specific selectors",
4315
+ "get_element_snapshot \u2014 deep snapshot of an element",
4316
+ "get_style_diff \u2014 compare styles between selectors"
4317
+ ]
4318
+ },
4319
+ issues,
4320
+ metadata: {
4321
+ timeRange: { from: Date.now() - result.scanDurationMs, to: Date.now() },
4322
+ eventCount: result.events.length,
4323
+ sessionId: result.events[0]?.sessionId ?? null,
4324
+ scanDurationMs: result.scanDurationMs
4325
+ }
4326
+ };
4327
+ return {
4328
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4329
+ };
4330
+ } catch (error) {
4331
+ const message = error instanceof Error ? error.message : String(error);
4332
+ let hint = "";
4333
+ if (message.includes("browserType.launch")) {
4334
+ hint = " Ensure Chromium is installed: npx playwright install chromium";
4335
+ } else if (message.includes("net::ERR_")) {
4336
+ hint = " The URL may be unreachable or blocked.";
4337
+ } else if (message.includes("Timeout")) {
4338
+ hint = ' The page took too long to load. Try with wait_for: "load" instead of "networkidle".';
4339
+ }
4340
+ return {
4341
+ content: [{
4342
+ type: "text",
4343
+ text: JSON.stringify({
4344
+ summary: `Scan failed: ${message}${hint}`,
4345
+ data: null,
4346
+ issues: [`Scan error: ${message}${hint}`],
4347
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
4348
+ }, null, 2)
4349
+ }]
4350
+ };
4351
+ }
4352
+ }
4353
+ );
4354
+ }
4355
+
4356
+ // src/tools/history.ts
4357
+ import { z as z26 } from "zod";
4358
+ var EVENT_TYPES = [
4359
+ "network",
4360
+ "console",
4361
+ "session",
4362
+ "state",
4363
+ "render",
4364
+ "dom_snapshot",
4365
+ "performance",
4366
+ "database",
4367
+ "recon_metadata",
4368
+ "recon_design_tokens",
4369
+ "recon_fonts",
4370
+ "recon_layout_tree",
4371
+ "recon_accessibility",
4372
+ "recon_computed_styles",
4373
+ "recon_element_snapshot",
4374
+ "recon_asset_inventory"
4375
+ ];
4376
+ function parseDateParam(value) {
4377
+ if (!value) return void 0;
4378
+ const relMatch = value.match(/^(\d+)(m|h|d|w)$/);
4379
+ if (relMatch) {
4380
+ const amount = parseInt(relMatch[1], 10);
4381
+ const unit = relMatch[2];
4382
+ const ms = { m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[unit];
4383
+ return Date.now() - amount * ms;
4384
+ }
4385
+ const num = Number(value);
4386
+ if (!isNaN(num) && num > 1e12) return num;
4387
+ const date = new Date(value);
4388
+ if (!isNaN(date.getTime())) return date.getTime();
4389
+ return void 0;
4390
+ }
4391
+ function registerHistoryTools(server, collector, projectManager) {
4392
+ server.tool(
4393
+ "get_historical_events",
4394
+ "Query past events from persistent SQLite storage. Use this to access events beyond the in-memory buffer (last 10K events). Events persist across Claude Code restarts. Filter by project, event type, time range, and session.",
4395
+ {
4396
+ project: z26.string().describe("Project/app name (the appName used in SDK init)"),
4397
+ event_types: z26.array(z26.enum(EVENT_TYPES)).optional().describe('Filter by event types (e.g., ["network", "console"])'),
4398
+ since: z26.string().optional().describe('Start time \u2014 relative ("2h", "7d", "30m") or ISO date string'),
4399
+ until: z26.string().optional().describe("End time \u2014 relative or ISO date string"),
4400
+ session_id: z26.string().optional().describe("Filter by specific session ID"),
4401
+ limit: z26.number().optional().default(200).describe("Max events to return (default 200, max 1000)"),
4402
+ offset: z26.number().optional().default(0).describe("Pagination offset")
4403
+ },
4404
+ async ({ project, event_types, since, until, session_id, limit, offset }) => {
4405
+ const sqliteStore = collector.getSqliteStore(project);
4406
+ if (!sqliteStore) {
4407
+ const projects = projectManager.listProjects();
4408
+ const hint = projects.length > 0 ? ` Available projects: ${projects.join(", ")}` : " No projects have connected yet.";
4409
+ return {
4410
+ content: [{ type: "text", text: JSON.stringify({
4411
+ summary: `No historical data for project "${project}".${hint}`,
4412
+ data: null,
4413
+ issues: [`Project "${project}" has no SQLite store. Connect an SDK with appName: "${project}" first.`],
4414
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
4415
+ }, null, 2) }]
4416
+ };
4417
+ }
4418
+ const sinceMs = parseDateParam(since);
4419
+ const untilMs = parseDateParam(until);
4420
+ const cappedLimit = Math.min(limit, 1e3);
4421
+ const events = sqliteStore.getEvents({
4422
+ project,
4423
+ sessionId: session_id,
4424
+ eventTypes: event_types,
4425
+ since: sinceMs,
4426
+ until: untilMs,
4427
+ limit: cappedLimit,
4428
+ offset
4429
+ });
4430
+ const totalCount = sqliteStore.getEventCount({
4431
+ project,
4432
+ sessionId: session_id,
4433
+ eventTypes: event_types,
4434
+ since: sinceMs,
4435
+ until: untilMs
4436
+ });
4437
+ const timeRange = events.length > 0 ? { from: events[0].timestamp, to: events[events.length - 1].timestamp } : { from: 0, to: 0 };
4438
+ const typeCounts = {};
4439
+ for (const e of events) {
4440
+ typeCounts[e.eventType] = (typeCounts[e.eventType] || 0) + 1;
4441
+ }
4442
+ const typeBreakdown = Object.entries(typeCounts).map(([type, count]) => `${type}: ${count}`).join(", ");
4443
+ const response = {
4444
+ summary: `${events.length} events returned (${totalCount} total matching). ${typeBreakdown || "No events."}${totalCount > cappedLimit + offset ? ` Use offset=${offset + cappedLimit} for next page.` : ""}`,
4445
+ data: {
4446
+ events,
4447
+ pagination: {
4448
+ returned: events.length,
4449
+ total: totalCount,
4450
+ limit: cappedLimit,
4451
+ offset,
4452
+ hasMore: offset + cappedLimit < totalCount
4453
+ }
4454
+ },
4455
+ issues: [],
4456
+ metadata: {
4457
+ timeRange,
4458
+ eventCount: events.length,
4459
+ sessionId: session_id ?? null
4460
+ }
4461
+ };
4462
+ return {
4463
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4464
+ };
4465
+ }
4466
+ );
4467
+ server.tool(
4468
+ "list_projects",
4469
+ "List all projects with stored historical data. Shows project names, event counts, session counts, and date ranges from SQLite persistence.",
4470
+ {},
4471
+ async () => {
4472
+ const projectNames = projectManager.listProjects();
4473
+ const projects = projectNames.map((name) => {
4474
+ const sqliteStore = collector.getSqliteStore(name);
4475
+ if (!sqliteStore) {
4476
+ return {
4477
+ name,
4478
+ eventCount: 0,
4479
+ sessionCount: 0,
4480
+ isConnected: false,
4481
+ note: "Project directory exists but no active SQLite store (SDK has not connected this session)"
4482
+ };
4483
+ }
4484
+ const eventCount = sqliteStore.getEventCount({ project: name });
4485
+ const sessions = sqliteStore.getSessions(name, 100);
4486
+ const connectedSessions = sessions.filter((s) => s.isConnected);
4487
+ return {
4488
+ name,
4489
+ eventCount,
4490
+ sessionCount: sessions.length,
4491
+ activeSessions: connectedSessions.length,
4492
+ isConnected: connectedSessions.length > 0,
4493
+ oldestSession: sessions.length > 0 ? new Date(sessions[sessions.length - 1].connectedAt).toISOString() : null,
4494
+ newestSession: sessions.length > 0 ? new Date(sessions[0].connectedAt).toISOString() : null
4495
+ };
4496
+ });
4497
+ const totalEvents = projects.reduce((s, p) => s + p.eventCount, 0);
4498
+ const connectedCount = projects.filter((p) => p.isConnected).length;
4499
+ const response = {
4500
+ summary: `${projects.length} project(s), ${totalEvents} total events, ${connectedCount} currently connected.`,
4501
+ data: projects,
4502
+ issues: [],
4503
+ metadata: {
4504
+ timeRange: { from: 0, to: 0 },
4505
+ eventCount: projects.length,
4506
+ sessionId: null
4507
+ }
4508
+ };
4509
+ return {
4510
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4511
+ };
4512
+ }
4513
+ );
4514
+ }
4515
+
4516
+ // src/index.ts
4517
+ var COLLECTOR_PORT2 = parseInt(process.env.RUNTIMESCOPE_PORT ?? "9090", 10);
4518
+ var HTTP_PORT2 = parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "9091", 10);
4519
+ var BUFFER_SIZE = parseInt(process.env.RUNTIMESCOPE_BUFFER_SIZE ?? "10000", 10);
4520
+ function killStaleProcess(port) {
4521
+ try {
4522
+ const pids = execSync2(`lsof -ti :${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
4523
+ if (pids) {
4524
+ const myPid = process.pid.toString();
4525
+ for (const pid of pids.split("\n")) {
4526
+ if (pid && pid !== myPid) {
4527
+ console.error(`[RuntimeScope] Killing stale process ${pid} on port ${port}`);
4528
+ try {
4529
+ process.kill(parseInt(pid, 10), "SIGTERM");
4530
+ } catch {
4531
+ }
4532
+ }
4533
+ }
4534
+ }
4535
+ } catch {
4536
+ }
4537
+ }
4538
+ async function main() {
4539
+ const projectManager = new ProjectManager();
4540
+ projectManager.ensureGlobalDir();
4541
+ const globalConfig = projectManager.getGlobalConfig();
4542
+ const authManager = new AuthManager({
4543
+ enabled: globalConfig.auth?.enabled ?? false,
4544
+ apiKeys: globalConfig.auth?.apiKeys ?? []
4545
+ });
4546
+ const tlsConfig = resolveTlsConfig() ?? globalConfig.tls ?? void 0;
4547
+ const redactor = new Redactor({
4548
+ enabled: globalConfig.redaction?.enabled ?? false,
4549
+ useBuiltIn: true,
4550
+ rules: globalConfig.redaction?.rules?.map((r) => ({
4551
+ name: r.name,
4552
+ pattern: new RegExp(r.pattern, "gi"),
4553
+ replacement: r.replacement
4554
+ }))
4555
+ });
4556
+ const corsOrigins = process.env.RUNTIMESCOPE_CORS_ORIGINS?.split(",").map((s) => s.trim()) ?? globalConfig.corsOrigins;
4557
+ if (authManager.isEnabled()) {
4558
+ console.error(`[RuntimeScope] Auth enabled (${globalConfig.auth?.apiKeys?.length ?? 0} API keys)`);
4559
+ }
4560
+ if (tlsConfig) {
4561
+ console.error(`[RuntimeScope] TLS enabled (cert: ${tlsConfig.certPath})`);
4562
+ }
4563
+ if (redactor.isEnabled()) {
4564
+ console.error("[RuntimeScope] Payload redaction enabled");
4565
+ }
4566
+ killStaleProcess(COLLECTOR_PORT2);
4567
+ killStaleProcess(HTTP_PORT2);
4568
+ const collector = new CollectorServer({
4569
+ bufferSize: BUFFER_SIZE,
4570
+ projectManager,
4571
+ authManager,
4572
+ rateLimits: globalConfig.rateLimits,
4573
+ tls: tlsConfig
4574
+ });
4575
+ await collector.start({ port: COLLECTOR_PORT2, maxRetries: 5, retryDelayMs: 1e3 });
4576
+ const store = collector.getStore();
4577
+ if (redactor.isEnabled()) {
4578
+ store.setRedactor(redactor);
4579
+ }
4580
+ const apiDiscovery = new ApiDiscoveryEngine(store);
4581
+ const connectionManager = new ConnectionManager();
4582
+ const schemaIntrospector = new SchemaIntrospector();
4583
+ const dataBrowser = new DataBrowser();
4584
+ const processMonitor = new ProcessMonitor(store);
4585
+ processMonitor.start();
4586
+ const infraConnector = new InfraConnector(store);
4587
+ const sqliteStores = collector.getSqliteStores();
4588
+ const sessionManager = new SessionManager(projectManager, sqliteStores, store);
4589
+ collector.onDisconnect((sessionId, projectName) => {
4590
+ try {
4591
+ sessionManager.createSnapshot(sessionId, projectName);
4592
+ console.error(`[RuntimeScope] Session ${sessionId} metrics saved to SQLite`);
4593
+ } catch {
4594
+ }
4595
+ });
4596
+ const RETENTION_DAYS = parseInt(process.env.RUNTIMESCOPE_RETENTION_DAYS ?? "30", 10);
4597
+ const cutoffMs = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1e3;
4598
+ for (const projectName of projectManager.listProjects()) {
4599
+ const dbPath = projectManager.getProjectDbPath(projectName);
4600
+ if (existsSync2(dbPath)) {
4601
+ try {
4602
+ const tempStore = new SqliteStore({ dbPath });
4603
+ const deleted = tempStore.deleteOldEvents(cutoffMs);
4604
+ if (deleted > 0) {
4605
+ console.error(`[RuntimeScope] Pruned ${deleted} events older than ${RETENTION_DAYS}d from "${projectName}"`);
4606
+ }
4607
+ tempStore.close();
4608
+ } catch {
4609
+ }
4610
+ }
4611
+ }
4612
+ const httpServer = new HttpServer(store, processMonitor, {
4613
+ authManager,
4614
+ allowedOrigins: corsOrigins
4615
+ });
4616
+ try {
4617
+ await httpServer.start({ port: HTTP_PORT2, tls: tlsConfig });
4618
+ } catch (err) {
4619
+ console.error("[RuntimeScope] HTTP API failed to start:", err.message);
4620
+ }
4621
+ const scanner = new PlaywrightScanner();
4622
+ const mcp = new McpServer({
4623
+ name: "runtimescope",
4624
+ version: "0.6.0"
4625
+ });
4626
+ registerNetworkTools(mcp, store);
4627
+ registerConsoleTools(mcp, store);
4628
+ registerSessionTools(mcp, store);
4629
+ registerIssueTools(mcp, store, apiDiscovery, processMonitor);
4630
+ registerTimelineTools(mcp, store);
4631
+ registerStateTools(mcp, store);
4632
+ registerRenderTools(mcp, store);
4633
+ registerPerformanceTools(mcp, store);
4634
+ registerDomSnapshotTools(mcp, store, collector);
4635
+ registerHarTools(mcp, store);
4636
+ registerErrorTools(mcp, store);
4637
+ registerApiDiscoveryTools(mcp, store, apiDiscovery);
4638
+ registerDatabaseTools(mcp, store, connectionManager, schemaIntrospector, dataBrowser);
4639
+ registerProcessMonitorTools(mcp, processMonitor);
4640
+ registerInfraTools(mcp, infraConnector);
4641
+ registerSessionDiffTools(mcp, sessionManager);
4642
+ registerReconMetadataTools(mcp, store, collector);
4643
+ registerReconDesignTokenTools(mcp, store, collector);
4644
+ registerReconFontTools(mcp, store);
4645
+ registerReconLayoutTools(mcp, store, collector);
4646
+ registerReconAccessibilityTools(mcp, store);
4647
+ registerReconComputedStyleTools(mcp, store, collector, scanner);
4648
+ registerReconElementSnapshotTools(mcp, store, collector, scanner);
4649
+ registerReconAssetTools(mcp, store);
4650
+ registerReconStyleDiffTools(mcp, store);
4651
+ registerScannerTools(mcp, store, scanner);
4652
+ registerHistoryTools(mcp, collector, projectManager);
4653
+ const transport = new StdioServerTransport();
4654
+ await mcp.connect(transport);
4655
+ console.error("[RuntimeScope] MCP server running on stdio (v0.6.0 \u2014 46 tools)");
4656
+ console.error(`[RuntimeScope] SDK snippet at http://127.0.0.1:${HTTP_PORT2}/snippet`);
4657
+ console.error(`[RuntimeScope] SDK should connect to ws://127.0.0.1:${COLLECTOR_PORT2}`);
4658
+ console.error(`[RuntimeScope] HTTP API at http://127.0.0.1:${HTTP_PORT2}`);
4659
+ let shuttingDown = false;
4660
+ const shutdown = async () => {
4661
+ if (shuttingDown) return;
4662
+ shuttingDown = true;
4663
+ processMonitor.stop();
4664
+ await scanner.shutdown();
4665
+ await connectionManager.closeAll();
4666
+ await httpServer.stop();
4667
+ collector.stop();
4668
+ process.exit(0);
4669
+ };
4670
+ process.on("SIGINT", () => {
4671
+ shutdown();
4672
+ });
4673
+ process.on("SIGTERM", () => {
4674
+ shutdown();
4675
+ });
4676
+ process.on("beforeExit", () => {
4677
+ shutdown();
4678
+ });
4679
+ process.stdin.on("end", () => {
4680
+ shutdown();
4681
+ });
4682
+ process.stdin.on("close", () => {
4683
+ shutdown();
4684
+ });
4685
+ }
4686
+ main().catch((err) => {
4687
+ console.error("[RuntimeScope] Fatal error:", err);
4688
+ process.exit(1);
4689
+ });
4690
+ //# sourceMappingURL=index.js.map