@runtimescope/mcp-server 0.6.1 → 0.7.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 CHANGED
@@ -5,6 +5,7 @@ import { execSync as execSync2 } from "child_process";
5
5
  import { existsSync as existsSync2 } from "fs";
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { join as join2 } from "path";
8
9
  import {
9
10
  CollectorServer,
10
11
  ProjectManager,
@@ -19,7 +20,9 @@ import {
19
20
  SqliteStore,
20
21
  AuthManager,
21
22
  Redactor,
22
- resolveTlsConfig
23
+ resolveTlsConfig,
24
+ PmStore,
25
+ ProjectDiscovery
23
26
  } from "@runtimescope/collector";
24
27
 
25
28
  // src/tools/network.ts
@@ -290,7 +293,7 @@ function registerTimelineTools(server, store) {
290
293
  "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
294
  {
292
295
  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)"),
296
+ event_types: z4.array(z4.enum(["network", "console", "session", "state", "render", "performance", "dom_snapshot", "database", "custom"])).optional().describe("Filter by event types (default: all)"),
294
297
  limit: z4.number().optional().describe("Max events to return (default: 200, max: 1000)")
295
298
  },
296
299
  async ({ since_seconds, event_types, limit }) => {
@@ -413,6 +416,14 @@ function formatTimelineEvent(event) {
413
416
  error: de.error ?? null
414
417
  };
415
418
  }
419
+ case "custom": {
420
+ const ce = event;
421
+ return {
422
+ ...base,
423
+ name: ce.name,
424
+ properties: ce.properties ?? null
425
+ };
426
+ }
416
427
  default:
417
428
  return base;
418
429
  }
@@ -1963,34 +1974,160 @@ function registerInfraTools(server, infraConnector) {
1963
1974
  // src/tools/session-diff.ts
1964
1975
  import { z as z15 } from "zod";
1965
1976
  import { compareSessions } from "@runtimescope/collector";
1966
- function registerSessionDiffTools(server, sessionManager) {
1977
+ function registerSessionDiffTools(server, sessionManager, collector) {
1978
+ server.tool(
1979
+ "create_session_snapshot",
1980
+ "Capture a point-in-time snapshot of a live or recent session. Use before/after code changes to compare how your app behaves at different moments. Each session can have multiple snapshots.",
1981
+ {
1982
+ session_id: z15.string().optional().describe("Session ID (defaults to first active session)"),
1983
+ label: z15.string().optional().describe('Label for this snapshot (e.g., "before-fix", "baseline", "after-deploy")'),
1984
+ project: z15.string().optional().describe("Project name")
1985
+ },
1986
+ async ({ session_id, label, project }) => {
1987
+ const sessionId = session_id ?? collector.getFirstSessionId();
1988
+ if (!sessionId) {
1989
+ return {
1990
+ content: [{ type: "text", text: JSON.stringify({
1991
+ summary: "No active session found. Connect an SDK first.",
1992
+ data: null,
1993
+ issues: ["No active sessions \u2014 connect an SDK with RuntimeScope.init()"],
1994
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1995
+ }, null, 2) }]
1996
+ };
1997
+ }
1998
+ const projectName = project ?? collector.getProjectForSession(sessionId) ?? "default";
1999
+ const snapshot = sessionManager.createSnapshot(sessionId, projectName, label);
2000
+ const response = {
2001
+ summary: `Snapshot captured for session ${sessionId.slice(0, 8)}${label ? ` (label: "${label}")` : ""}. ${snapshot.metrics.totalEvents} events, ${snapshot.metrics.errorCount} errors.`,
2002
+ data: {
2003
+ sessionId: snapshot.sessionId,
2004
+ project: snapshot.project,
2005
+ label: snapshot.label ?? null,
2006
+ createdAt: new Date(snapshot.createdAt).toISOString(),
2007
+ metrics: {
2008
+ totalEvents: snapshot.metrics.totalEvents,
2009
+ errorCount: snapshot.metrics.errorCount,
2010
+ endpointCount: Object.keys(snapshot.metrics.endpoints).length,
2011
+ componentCount: Object.keys(snapshot.metrics.components).length,
2012
+ storeCount: Object.keys(snapshot.metrics.stores).length,
2013
+ webVitalCount: Object.keys(snapshot.metrics.webVitals).length,
2014
+ queryCount: Object.keys(snapshot.metrics.queries).length
2015
+ }
2016
+ },
2017
+ issues: [],
2018
+ metadata: {
2019
+ timeRange: { from: snapshot.metrics.connectedAt, to: snapshot.metrics.disconnectedAt },
2020
+ eventCount: snapshot.metrics.totalEvents,
2021
+ sessionId: snapshot.sessionId
2022
+ }
2023
+ };
2024
+ return {
2025
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2026
+ };
2027
+ }
2028
+ );
2029
+ server.tool(
2030
+ "get_session_snapshots",
2031
+ "List all snapshots for a session. Use with compare_sessions to track how your app changed over time within a single session.",
2032
+ {
2033
+ session_id: z15.string().describe("Session ID"),
2034
+ project: z15.string().optional().describe("Project name")
2035
+ },
2036
+ async ({ session_id, project }) => {
2037
+ const projectName = project ?? collector.getProjectForSession(session_id) ?? "default";
2038
+ const snapshots = sessionManager.getSessionSnapshots(projectName, session_id);
2039
+ const response = {
2040
+ summary: `${snapshots.length} snapshot(s) for session ${session_id.slice(0, 8)}.`,
2041
+ data: snapshots.map((s) => ({
2042
+ id: s.id,
2043
+ sessionId: s.sessionId,
2044
+ label: s.label ?? null,
2045
+ createdAt: new Date(s.createdAt).toISOString(),
2046
+ totalEvents: s.metrics.totalEvents,
2047
+ errorCount: s.metrics.errorCount,
2048
+ endpointCount: Object.keys(s.metrics.endpoints).length,
2049
+ componentCount: Object.keys(s.metrics.components).length
2050
+ })),
2051
+ issues: [],
2052
+ metadata: {
2053
+ timeRange: snapshots.length > 0 ? { from: snapshots[0].createdAt, to: snapshots[snapshots.length - 1].createdAt } : { from: 0, to: 0 },
2054
+ eventCount: snapshots.length,
2055
+ sessionId: session_id
2056
+ }
2057
+ };
2058
+ return {
2059
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2060
+ };
2061
+ }
2062
+ );
1967
2063
  server.tool(
1968
2064
  "compare_sessions",
1969
- "Compare two sessions: render counts, API latency, errors, Web Vitals, and query performance. Shows regressions and improvements.",
2065
+ "Compare two sessions or two snapshots: render counts, API latency, errors, Web Vitals, and query performance. Shows regressions and improvements. Use snapshot_a/snapshot_b to compare specific snapshots within or across sessions.",
1970
2066
  {
1971
- session_a: z15.string().describe("First session ID (baseline)"),
1972
- session_b: z15.string().describe("Second session ID (comparison)"),
2067
+ session_a: z15.string().optional().describe("First session ID (baseline) \u2014 used when comparing sessions"),
2068
+ session_b: z15.string().optional().describe("Second session ID (comparison) \u2014 used when comparing sessions"),
2069
+ snapshot_a: z15.number().optional().describe("First snapshot ID (baseline) \u2014 used when comparing snapshots"),
2070
+ snapshot_b: z15.number().optional().describe("Second snapshot ID (comparison) \u2014 used when comparing snapshots"),
1973
2071
  project: z15.string().optional().describe("Project name")
1974
2072
  },
1975
- async ({ session_a, session_b, project }) => {
2073
+ async ({ session_a, session_b, snapshot_a, snapshot_b, project }) => {
1976
2074
  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) {
2075
+ let metricsA = null;
2076
+ let metricsB = null;
2077
+ let labelA = "";
2078
+ let labelB = "";
2079
+ if (snapshot_a != null && snapshot_b != null) {
2080
+ const snapA = sessionManager.getSnapshotById(projectName, snapshot_a);
2081
+ const snapB = sessionManager.getSnapshotById(projectName, snapshot_b);
2082
+ if (!snapA || !snapB) {
2083
+ return {
2084
+ content: [{ type: "text", text: JSON.stringify({
2085
+ summary: "Could not find one or both snapshots.",
2086
+ data: null,
2087
+ issues: [
2088
+ !snapA ? `Snapshot ${snapshot_a} not found` : null,
2089
+ !snapB ? `Snapshot ${snapshot_b} not found` : null
2090
+ ].filter(Boolean),
2091
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
2092
+ }, null, 2) }]
2093
+ };
2094
+ }
2095
+ metricsA = snapA.metrics;
2096
+ metricsB = snapB.metrics;
2097
+ labelA = snapA.label ? ` (${snapA.label})` : ` (snapshot #${snapshot_a})`;
2098
+ labelB = snapB.label ? ` (${snapB.label})` : ` (snapshot #${snapshot_b})`;
2099
+ } else if (session_a && session_b) {
2100
+ const history = sessionManager.getSessionHistory(projectName, 100);
2101
+ const snapshotA = history.find((s) => s.sessionId === session_a);
2102
+ const snapshotB = history.find((s) => s.sessionId === session_b);
2103
+ if (!snapshotA || !snapshotB) {
2104
+ return {
2105
+ content: [{ type: "text", text: JSON.stringify({
2106
+ summary: "Could not find one or both sessions in history.",
2107
+ data: null,
2108
+ issues: [
2109
+ !snapshotA ? `Session ${session_a} not found` : null,
2110
+ !snapshotB ? `Session ${session_b} not found` : null
2111
+ ].filter(Boolean),
2112
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
2113
+ }, null, 2) }]
2114
+ };
2115
+ }
2116
+ metricsA = snapshotA.metrics;
2117
+ metricsB = snapshotB.metrics;
2118
+ labelA = ` (session ${session_a.slice(0, 8)})`;
2119
+ labelB = ` (session ${session_b.slice(0, 8)})`;
2120
+ } else {
1981
2121
  return {
1982
2122
  content: [{ type: "text", text: JSON.stringify({
1983
- summary: "Could not find one or both sessions in history.",
2123
+ summary: "Provide either session_a + session_b or snapshot_a + snapshot_b.",
1984
2124
  data: null,
1985
- issues: [
1986
- !snapshotA ? `Session ${session_a} not found` : null,
1987
- !snapshotB ? `Session ${session_b} not found` : null
1988
- ].filter(Boolean),
2125
+ issues: ["Must provide either two session IDs or two snapshot IDs to compare."],
1989
2126
  metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1990
2127
  }, null, 2) }]
1991
2128
  };
1992
2129
  }
1993
- const diff = compareSessions(snapshotA.metrics, snapshotB.metrics);
2130
+ const diff = compareSessions(metricsA, metricsB);
1994
2131
  const regressions = [
1995
2132
  ...diff.endpointDeltas.filter((d) => d.classification === "regression"),
1996
2133
  ...diff.componentDeltas.filter((d) => d.classification === "regression"),
@@ -2004,7 +2141,7 @@ function registerSessionDiffTools(server, sessionManager) {
2004
2141
  ...diff.queryDeltas.filter((d) => d.classification === "improvement")
2005
2142
  ];
2006
2143
  const response = {
2007
- summary: `Session comparison: ${regressions.length} regression(s), ${improvements.length} improvement(s). Error delta: ${diff.overallDelta.errorCountDelta >= 0 ? "+" : ""}${diff.overallDelta.errorCountDelta}.`,
2144
+ summary: `Comparison${labelA} vs${labelB}: ${regressions.length} regression(s), ${improvements.length} improvement(s). Error delta: ${diff.overallDelta.errorCountDelta >= 0 ? "+" : ""}${diff.overallDelta.errorCountDelta}.`,
2008
2145
  data: {
2009
2146
  endpointDeltas: diff.endpointDeltas.map((d) => ({
2010
2147
  ...d,
@@ -2030,7 +2167,7 @@ function registerSessionDiffTools(server, sessionManager) {
2030
2167
  },
2031
2168
  issues: regressions.map((r) => `Regression: ${r.key} (${(r.percentChange * 100).toFixed(1)}% worse)`),
2032
2169
  metadata: {
2033
- timeRange: { from: snapshotA.createdAt, to: snapshotB.createdAt },
2170
+ timeRange: { from: metricsA.connectedAt, to: metricsB.disconnectedAt },
2034
2171
  eventCount: 2,
2035
2172
  sessionId: null
2036
2173
  }
@@ -4353,8 +4490,241 @@ RuntimeScope.init({
4353
4490
  );
4354
4491
  }
4355
4492
 
4356
- // src/tools/history.ts
4493
+ // src/tools/custom-events.ts
4357
4494
  import { z as z26 } from "zod";
4495
+ function registerCustomEventTools(server, store) {
4496
+ server.tool(
4497
+ "get_custom_events",
4498
+ "Get custom business/product events tracked via RuntimeScope.track(). Shows event catalog (all unique event names with counts) and recent occurrences. Use this to see what events are being tracked and their frequency.",
4499
+ {
4500
+ name: z26.string().optional().describe("Filter by event name (exact match)"),
4501
+ since_seconds: z26.number().optional().describe("Only events from the last N seconds (default: 300)"),
4502
+ session_id: z26.string().optional().describe("Filter by session ID")
4503
+ },
4504
+ async ({ name, since_seconds, session_id }) => {
4505
+ const sinceSeconds = since_seconds ?? 300;
4506
+ const events = store.getCustomEvents({
4507
+ name,
4508
+ sinceSeconds,
4509
+ sessionId: session_id
4510
+ });
4511
+ const catalog = {};
4512
+ for (const e of events) {
4513
+ if (!catalog[e.name]) {
4514
+ catalog[e.name] = { count: 0, lastSeen: 0, sampleProperties: void 0 };
4515
+ }
4516
+ catalog[e.name].count++;
4517
+ if (e.timestamp > catalog[e.name].lastSeen) {
4518
+ catalog[e.name].lastSeen = e.timestamp;
4519
+ catalog[e.name].sampleProperties = e.properties;
4520
+ }
4521
+ }
4522
+ const catalogList = Object.entries(catalog).sort((a, b) => b[1].lastSeen - a[1].lastSeen).map(([eventName, info]) => ({
4523
+ name: eventName,
4524
+ count: info.count,
4525
+ lastSeen: new Date(info.lastSeen).toISOString(),
4526
+ sampleProperties: info.sampleProperties
4527
+ }));
4528
+ const sessions = store.getSessionInfo();
4529
+ const sessionId = session_id ?? sessions[0]?.sessionId ?? null;
4530
+ const response = {
4531
+ summary: `${events.length} custom event(s) across ${catalogList.length} unique event name(s) in the last ${sinceSeconds}s.${name ? ` Filtered by: "${name}".` : ""}`,
4532
+ data: {
4533
+ catalog: catalogList,
4534
+ recentEvents: events.slice(0, 100).map((e) => ({
4535
+ name: e.name,
4536
+ timestamp: new Date(e.timestamp).toISOString(),
4537
+ properties: e.properties,
4538
+ sessionId: e.sessionId
4539
+ }))
4540
+ },
4541
+ issues: [],
4542
+ metadata: {
4543
+ timeRange: {
4544
+ from: events.length > 0 ? events[events.length - 1].timestamp : 0,
4545
+ to: events.length > 0 ? events[0].timestamp : 0
4546
+ },
4547
+ eventCount: events.length,
4548
+ sessionId
4549
+ }
4550
+ };
4551
+ return {
4552
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4553
+ };
4554
+ }
4555
+ );
4556
+ server.tool(
4557
+ "get_event_flow",
4558
+ "Analyze a user flow as a funnel. Given an ordered list of custom event names (steps), shows how many sessions completed each step, where drop-offs happen, and what errors/failures occurred between steps. Each step includes correlated telemetry (network errors, console errors, failed DB queries) that happened between the previous step and this one \u2014 this is the key to finding WHY a step failed.",
4559
+ {
4560
+ steps: z26.array(z26.string()).min(2).describe('Ordered list of custom event names representing the flow (e.g. ["create_profile", "generate_campaign", "export_ad"])'),
4561
+ since_seconds: z26.number().optional().describe("Only analyze events from the last N seconds (default: 3600)"),
4562
+ session_id: z26.string().optional().describe("Analyze a specific session (default: all sessions)")
4563
+ },
4564
+ async ({ steps, since_seconds, session_id }) => {
4565
+ const sinceSeconds = since_seconds ?? 3600;
4566
+ const allCustom = store.getCustomEvents({ sinceSeconds, sessionId: session_id });
4567
+ const bySession = /* @__PURE__ */ new Map();
4568
+ for (const e of allCustom) {
4569
+ if (!bySession.has(e.sessionId)) bySession.set(e.sessionId, []);
4570
+ bySession.get(e.sessionId).push(e);
4571
+ }
4572
+ for (const events of bySession.values()) {
4573
+ events.sort((a, b) => a.timestamp - b.timestamp);
4574
+ }
4575
+ const networkErrors = store.getNetworkRequests({ sinceSeconds, sessionId: session_id }).filter((e) => e.status >= 400 || e.errorPhase);
4576
+ const consoleErrors = store.getConsoleMessages({ sinceSeconds, sessionId: session_id }).filter((e) => e.level === "error");
4577
+ const dbErrors = store.getDatabaseEvents({ sinceSeconds, sessionId: session_id }).filter((e) => !!e.error);
4578
+ const stepResults = steps.map(() => ({
4579
+ step: "",
4580
+ sessionsReached: 0,
4581
+ sessionsCompleted: 0,
4582
+ avgTimeFromPrevMs: null,
4583
+ correlatedErrors: { networkErrors: [], consoleErrors: [], dbErrors: [] }
4584
+ }));
4585
+ const totalSessions = bySession.size;
4586
+ const completedFlows = [];
4587
+ for (const [sessionId, sessionEvents] of bySession) {
4588
+ let prevStepTime = null;
4589
+ let completedAll = true;
4590
+ for (let i = 0; i < steps.length; i++) {
4591
+ const stepName = steps[i];
4592
+ stepResults[i].step = stepName;
4593
+ const occurrence = sessionEvents.find(
4594
+ (e) => e.name === stepName && (prevStepTime === null || e.timestamp >= prevStepTime)
4595
+ );
4596
+ if (!occurrence) {
4597
+ completedAll = false;
4598
+ if (prevStepTime !== null) {
4599
+ const gapEnd = sessionEvents[sessionEvents.length - 1]?.timestamp ?? Date.now();
4600
+ collectCorrelatedErrors(stepResults[i], sessionId, prevStepTime, gapEnd, networkErrors, consoleErrors, dbErrors);
4601
+ }
4602
+ break;
4603
+ }
4604
+ stepResults[i].sessionsReached++;
4605
+ if (prevStepTime !== null) {
4606
+ const delta = occurrence.timestamp - prevStepTime;
4607
+ if (stepResults[i].avgTimeFromPrevMs === null) {
4608
+ stepResults[i].avgTimeFromPrevMs = delta;
4609
+ } else {
4610
+ stepResults[i].avgTimeFromPrevMs = (stepResults[i].avgTimeFromPrevMs * (stepResults[i].sessionsReached - 1) + delta) / stepResults[i].sessionsReached;
4611
+ }
4612
+ collectCorrelatedErrors(stepResults[i], sessionId, prevStepTime, occurrence.timestamp, networkErrors, consoleErrors, dbErrors);
4613
+ } else {
4614
+ stepResults[i].sessionsReached++;
4615
+ }
4616
+ stepResults[i].sessionsCompleted++;
4617
+ prevStepTime = occurrence.timestamp;
4618
+ }
4619
+ if (completedAll && prevStepTime !== null) {
4620
+ const firstStep = sessionEvents.find((e) => e.name === steps[0]);
4621
+ if (firstStep) {
4622
+ completedFlows.push({ sessionId, totalDurationMs: prevStepTime - firstStep.timestamp });
4623
+ }
4624
+ }
4625
+ }
4626
+ for (let i = 0; i < stepResults.length; i++) {
4627
+ stepResults[i].step = steps[i];
4628
+ }
4629
+ for (const step of stepResults) {
4630
+ step.correlatedErrors.networkErrors = dedup(step.correlatedErrors.networkErrors, 5);
4631
+ step.correlatedErrors.consoleErrors = dedup(step.correlatedErrors.consoleErrors, 5);
4632
+ step.correlatedErrors.dbErrors = dedup(step.correlatedErrors.dbErrors, 5);
4633
+ }
4634
+ const funnelSteps = stepResults.map((s, i) => ({
4635
+ step: s.step,
4636
+ reached: s.sessionsCompleted,
4637
+ conversionRate: i === 0 ? totalSessions > 0 ? `${(s.sessionsCompleted / totalSessions * 100).toFixed(1)}%` : "0%" : stepResults[i - 1].sessionsCompleted > 0 ? `${(s.sessionsCompleted / stepResults[i - 1].sessionsCompleted * 100).toFixed(1)}%` : "0%",
4638
+ avgTimeFromPrev: s.avgTimeFromPrevMs !== null ? `${Math.round(s.avgTimeFromPrevMs)}ms` : null,
4639
+ errorsBetweenSteps: {
4640
+ network: s.correlatedErrors.networkErrors.length,
4641
+ console: s.correlatedErrors.consoleErrors.length,
4642
+ database: s.correlatedErrors.dbErrors.length
4643
+ },
4644
+ correlatedErrors: s.correlatedErrors
4645
+ }));
4646
+ const avgCompletionTime = completedFlows.length > 0 ? Math.round(completedFlows.reduce((sum, f) => sum + f.totalDurationMs, 0) / completedFlows.length) : null;
4647
+ const issues = [];
4648
+ for (let i = 1; i < funnelSteps.length; i++) {
4649
+ const prev = funnelSteps[i - 1].reached;
4650
+ const curr = funnelSteps[i].reached;
4651
+ if (prev > 0 && curr / prev < 0.5) {
4652
+ issues.push(`Major drop-off at "${steps[i]}": only ${(curr / prev * 100).toFixed(0)}% conversion from "${steps[i - 1]}"`);
4653
+ }
4654
+ const totalErrors = funnelSteps[i].errorsBetweenSteps.network + funnelSteps[i].errorsBetweenSteps.console + funnelSteps[i].errorsBetweenSteps.database;
4655
+ if (totalErrors > 0) {
4656
+ issues.push(`${totalErrors} error(s) detected between "${steps[i - 1]}" and "${steps[i]}"`);
4657
+ }
4658
+ }
4659
+ const response = {
4660
+ summary: `Flow analysis: ${steps.length} steps, ${totalSessions} session(s), ${completedFlows.length} completed the full flow.${avgCompletionTime ? ` Avg completion: ${avgCompletionTime}ms.` : ""}`,
4661
+ data: {
4662
+ totalSessions,
4663
+ completedFlows: completedFlows.length,
4664
+ avgCompletionTimeMs: avgCompletionTime,
4665
+ funnel: funnelSteps
4666
+ },
4667
+ issues,
4668
+ metadata: {
4669
+ timeRange: {
4670
+ from: allCustom.length > 0 ? allCustom[allCustom.length - 1].timestamp : 0,
4671
+ to: allCustom.length > 0 ? allCustom[0].timestamp : 0
4672
+ },
4673
+ eventCount: allCustom.length,
4674
+ sessionId: session_id ?? null
4675
+ }
4676
+ };
4677
+ return {
4678
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4679
+ };
4680
+ }
4681
+ );
4682
+ }
4683
+ function collectCorrelatedErrors(result, sessionId, fromTs, toTs, networkErrors, consoleErrors, dbErrors) {
4684
+ for (const e of networkErrors) {
4685
+ if (e.sessionId === sessionId && e.timestamp >= fromTs && e.timestamp <= toTs) {
4686
+ result.correlatedErrors.networkErrors.push({
4687
+ url: e.url,
4688
+ status: e.status,
4689
+ method: e.method,
4690
+ timestamp: new Date(e.timestamp).toISOString()
4691
+ });
4692
+ }
4693
+ }
4694
+ for (const e of consoleErrors) {
4695
+ if (e.sessionId === sessionId && e.timestamp >= fromTs && e.timestamp <= toTs) {
4696
+ result.correlatedErrors.consoleErrors.push({
4697
+ message: e.message.length > 200 ? e.message.slice(0, 200) + "..." : e.message,
4698
+ timestamp: new Date(e.timestamp).toISOString()
4699
+ });
4700
+ }
4701
+ }
4702
+ for (const e of dbErrors) {
4703
+ if (e.sessionId === sessionId && e.timestamp >= fromTs && e.timestamp <= toTs) {
4704
+ result.correlatedErrors.dbErrors.push({
4705
+ query: e.query.length > 150 ? e.query.slice(0, 150) + "..." : e.query,
4706
+ error: e.error,
4707
+ timestamp: new Date(e.timestamp).toISOString()
4708
+ });
4709
+ }
4710
+ }
4711
+ }
4712
+ function dedup(arr, limit) {
4713
+ const seen = /* @__PURE__ */ new Set();
4714
+ const result = [];
4715
+ for (const item of arr) {
4716
+ const key = JSON.stringify(item);
4717
+ if (!seen.has(key)) {
4718
+ seen.add(key);
4719
+ result.push(item);
4720
+ if (result.length >= limit) break;
4721
+ }
4722
+ }
4723
+ return result;
4724
+ }
4725
+
4726
+ // src/tools/history.ts
4727
+ import { z as z27 } from "zod";
4358
4728
  var EVENT_TYPES = [
4359
4729
  "network",
4360
4730
  "console",
@@ -4393,13 +4763,13 @@ function registerHistoryTools(server, collector, projectManager) {
4393
4763
  "get_historical_events",
4394
4764
  "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
4765
  {
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")
4766
+ project: z27.string().describe("Project/app name (the appName used in SDK init)"),
4767
+ event_types: z27.array(z27.enum(EVENT_TYPES)).optional().describe('Filter by event types (e.g., ["network", "console"])'),
4768
+ since: z27.string().optional().describe('Start time \u2014 relative ("2h", "7d", "30m") or ISO date string'),
4769
+ until: z27.string().optional().describe("End time \u2014 relative or ISO date string"),
4770
+ session_id: z27.string().optional().describe("Filter by specific session ID"),
4771
+ limit: z27.number().optional().default(200).describe("Max events to return (default 200, max 1000)"),
4772
+ offset: z27.number().optional().default(0).describe("Pagination offset")
4403
4773
  },
4404
4774
  async ({ project, event_types, since, until, session_id, limit, offset }) => {
4405
4775
  const sqliteStore = collector.getSqliteStore(project);
@@ -4588,11 +4958,25 @@ async function main() {
4588
4958
  const sessionManager = new SessionManager(projectManager, sqliteStores, store);
4589
4959
  collector.onDisconnect((sessionId, projectName) => {
4590
4960
  try {
4591
- sessionManager.createSnapshot(sessionId, projectName);
4961
+ sessionManager.createSnapshot(sessionId, projectName, "auto-disconnect");
4592
4962
  console.error(`[RuntimeScope] Session ${sessionId} metrics saved to SQLite`);
4593
4963
  } catch {
4594
4964
  }
4595
4965
  });
4966
+ const AUTO_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1e3;
4967
+ let autoSnapshotCount = 0;
4968
+ const autoSnapshotTimer = setInterval(() => {
4969
+ const sessions = collector.getConnectedSessions();
4970
+ if (sessions.length === 0) return;
4971
+ autoSnapshotCount++;
4972
+ const minutes = autoSnapshotCount * 5;
4973
+ for (const { sessionId, projectName } of sessions) {
4974
+ try {
4975
+ sessionManager.createSnapshot(sessionId, projectName, `auto-${minutes}m`);
4976
+ } catch {
4977
+ }
4978
+ }
4979
+ }, AUTO_SNAPSHOT_INTERVAL_MS);
4596
4980
  const RETENTION_DAYS = parseInt(process.env.RUNTIMESCOPE_RETENTION_DAYS ?? "30", 10);
4597
4981
  const cutoffMs = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1e3;
4598
4982
  for (const projectName of projectManager.listProjects()) {
@@ -4609,15 +4993,32 @@ async function main() {
4609
4993
  }
4610
4994
  }
4611
4995
  }
4996
+ const pmDbPath = join2(projectManager.rootDir, "pm.db");
4997
+ const pmStore = new PmStore({ dbPath: pmDbPath });
4998
+ const discovery = new ProjectDiscovery(pmStore, projectManager);
4999
+ discovery.discoverAll().then((result) => {
5000
+ console.error(`[RuntimeScope] PM: ${result.projectsDiscovered} projects, ${result.sessionsDiscovered} sessions discovered`);
5001
+ }).catch((err) => {
5002
+ console.error("[RuntimeScope] PM discovery error:", err.message);
5003
+ });
4612
5004
  const httpServer = new HttpServer(store, processMonitor, {
4613
5005
  authManager,
4614
- allowedOrigins: corsOrigins
5006
+ allowedOrigins: corsOrigins,
5007
+ rateLimiter: collector.getRateLimiter(),
5008
+ pmStore,
5009
+ discovery
4615
5010
  });
4616
5011
  try {
4617
5012
  await httpServer.start({ port: HTTP_PORT2, tls: tlsConfig });
4618
5013
  } catch (err) {
4619
5014
  console.error("[RuntimeScope] HTTP API failed to start:", err.message);
4620
5015
  }
5016
+ collector.onConnect((sessionId, projectName) => {
5017
+ httpServer.broadcastSessionChange("session_connected", sessionId, projectName);
5018
+ });
5019
+ collector.onDisconnect((sessionId, projectName) => {
5020
+ httpServer.broadcastSessionChange("session_disconnected", sessionId, projectName);
5021
+ });
4621
5022
  const scanner = new PlaywrightScanner();
4622
5023
  const mcp = new McpServer({
4623
5024
  name: "runtimescope",
@@ -4638,7 +5039,7 @@ async function main() {
4638
5039
  registerDatabaseTools(mcp, store, connectionManager, schemaIntrospector, dataBrowser);
4639
5040
  registerProcessMonitorTools(mcp, processMonitor);
4640
5041
  registerInfraTools(mcp, infraConnector);
4641
- registerSessionDiffTools(mcp, sessionManager);
5042
+ registerSessionDiffTools(mcp, sessionManager, collector);
4642
5043
  registerReconMetadataTools(mcp, store, collector);
4643
5044
  registerReconDesignTokenTools(mcp, store, collector);
4644
5045
  registerReconFontTools(mcp, store);
@@ -4649,6 +5050,7 @@ async function main() {
4649
5050
  registerReconAssetTools(mcp, store);
4650
5051
  registerReconStyleDiffTools(mcp, store);
4651
5052
  registerScannerTools(mcp, store, scanner);
5053
+ registerCustomEventTools(mcp, store);
4652
5054
  registerHistoryTools(mcp, collector, projectManager);
4653
5055
  const transport = new StdioServerTransport();
4654
5056
  await mcp.connect(transport);
@@ -4660,11 +5062,13 @@ async function main() {
4660
5062
  const shutdown = async () => {
4661
5063
  if (shuttingDown) return;
4662
5064
  shuttingDown = true;
5065
+ clearInterval(autoSnapshotTimer);
4663
5066
  processMonitor.stop();
4664
5067
  await scanner.shutdown();
4665
5068
  await connectionManager.closeAll();
4666
5069
  await httpServer.stop();
4667
5070
  collector.stop();
5071
+ pmStore.close();
4668
5072
  process.exit(0);
4669
5073
  };
4670
5074
  process.on("SIGINT", () => {