what-devtools-mcp 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.
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Extended MCP tool definitions for what-devtools-mcp.
3
+ * 8 tools: component tree, dependency graph, eval, DOM inspect,
4
+ * route info, diagnostics, diff snapshot, navigate.
5
+ *
6
+ * These supplement the 10 base tools in tools.js.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+
11
+ export function registerExtendedTools(server, bridge) {
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helper responses (local copies — tools.js owns the originals)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function noConnection(tool) {
18
+ return {
19
+ content: [{
20
+ type: 'text',
21
+ text: JSON.stringify({
22
+ error: 'No browser connected',
23
+ summary: `Cannot reach browser for ${tool}.`,
24
+ nextSteps: [
25
+ 'Ensure your What Framework app is running with the devtools-mcp Vite plugin enabled.',
26
+ 'Or call connectDevToolsMCP() manually in the browser console.',
27
+ 'Check that the bridge server is started (default port 9229).',
28
+ ],
29
+ }, null, 2),
30
+ }],
31
+ isError: true,
32
+ };
33
+ }
34
+
35
+ function noSnapshot(tool) {
36
+ return {
37
+ content: [{
38
+ type: 'text',
39
+ text: JSON.stringify({
40
+ error: 'No snapshot available',
41
+ summary: 'Browser is connected but no state snapshot has been received yet.',
42
+ nextSteps: [
43
+ 'Try refreshing the page in the browser.',
44
+ 'Ensure __WHAT_DEVTOOLS__ is initialized before connectDevToolsMCP().',
45
+ ],
46
+ }, null, 2),
47
+ }],
48
+ isError: true,
49
+ };
50
+ }
51
+
52
+ function errorResponse(message, nextSteps) {
53
+ return {
54
+ content: [{
55
+ type: 'text',
56
+ text: JSON.stringify({
57
+ error: message,
58
+ summary: message,
59
+ nextSteps: nextSteps || ['Check the arguments and try again.'],
60
+ }, null, 2),
61
+ }],
62
+ isError: true,
63
+ };
64
+ }
65
+
66
+ function ok(data) {
67
+ return {
68
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Helper: get a fresh or cached snapshot
74
+ // ---------------------------------------------------------------------------
75
+
76
+ async function freshSnapshot(toolName) {
77
+ if (!bridge.isConnected()) return { err: noConnection(toolName) };
78
+ let snapshot;
79
+ try {
80
+ snapshot = bridge.getOrRefreshSnapshot
81
+ ? await bridge.getOrRefreshSnapshot()
82
+ : await bridge.refreshSnapshot();
83
+ } catch {
84
+ snapshot = bridge.getSnapshot();
85
+ }
86
+ if (!snapshot) return { err: noSnapshot(toolName) };
87
+ return { snapshot };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Tool 1 — what_component_tree
92
+ // ---------------------------------------------------------------------------
93
+
94
+ server.tool(
95
+ 'what_component_tree',
96
+ 'Get the component hierarchy as a tree structure. Shows parent-child relationships, signal/effect counts per component.',
97
+ {
98
+ rootId: z.number().optional().describe('Start from this component ID (default: full tree)'),
99
+ depth: z.number().optional().default(10).describe('Max depth to traverse (default: 10)'),
100
+ filter: z.string().optional().describe('Only include subtrees containing components matching this regex'),
101
+ },
102
+ async ({ rootId, depth, filter }) => {
103
+ const { snapshot, err } = await freshSnapshot('what_component_tree');
104
+ if (err) return err;
105
+
106
+ const components = snapshot.components || [];
107
+ if (components.length === 0) {
108
+ return ok({ tree: null, summary: '0 components mounted.' });
109
+ }
110
+
111
+ // Index by id
112
+ const byId = new Map();
113
+ for (const c of components) {
114
+ byId.set(c.id, { ...c, children: [] });
115
+ }
116
+
117
+ // Build parent-child links
118
+ const roots = [];
119
+ for (const node of byId.values()) {
120
+ if (node.parentId != null && byId.has(node.parentId)) {
121
+ byId.get(node.parentId).children.push(node);
122
+ } else {
123
+ roots.push(node);
124
+ }
125
+ }
126
+
127
+ // Attach signal/effect counts per component
128
+ const signals = snapshot.signals || [];
129
+ const effects = snapshot.effects || [];
130
+ for (const node of byId.values()) {
131
+ node.signalCount = signals.filter(s => s.componentId === node.id).length;
132
+ node.effectCount = effects.filter(e => e.componentId === node.id).length;
133
+ }
134
+
135
+ // Optional filter — keep subtrees that contain a matching node
136
+ let filterRe = null;
137
+ if (filter) {
138
+ try {
139
+ filterRe = new RegExp(filter, 'i');
140
+ } catch {
141
+ return errorResponse(`Invalid regex: ${filter}`, ['Provide a valid JavaScript regex pattern.']);
142
+ }
143
+ }
144
+
145
+ function matchesFilter(node) {
146
+ if (!filterRe) return true;
147
+ if (filterRe.test(node.name)) return true;
148
+ return node.children.some(c => matchesFilter(c));
149
+ }
150
+
151
+ // Prune to requested depth and serialize
152
+ function toTree(node, currentDepth) {
153
+ if (currentDepth > depth) return null;
154
+ if (filterRe && !matchesFilter(node)) return null;
155
+ const result = {
156
+ id: node.id,
157
+ name: node.name,
158
+ parentId: node.parentId ?? null,
159
+ signalCount: node.signalCount,
160
+ effectCount: node.effectCount,
161
+ };
162
+ const kids = [];
163
+ for (const child of node.children) {
164
+ const serialized = toTree(child, currentDepth + 1);
165
+ if (serialized) kids.push(serialized);
166
+ }
167
+ if (kids.length) result.children = kids;
168
+ return result;
169
+ }
170
+
171
+ let tree;
172
+ if (rootId != null) {
173
+ const startNode = byId.get(rootId);
174
+ if (!startNode) {
175
+ return errorResponse(`Component with id ${rootId} not found.`, [
176
+ 'Use what_components to list available component IDs.',
177
+ ]);
178
+ }
179
+ tree = toTree(startNode, 0);
180
+ } else {
181
+ tree = roots.map(r => toTree(r, 0)).filter(Boolean);
182
+ }
183
+
184
+ // Compute max depth and flat label summary
185
+ let maxDepth = 0;
186
+ let totalNodes = 0;
187
+ function measureDepth(node, d) {
188
+ if (!node) return;
189
+ totalNodes++;
190
+ if (d > maxDepth) maxDepth = d;
191
+ for (const c of (node.children || [])) measureDepth(c, d + 1);
192
+ }
193
+ const treeArray = Array.isArray(tree) ? tree : [tree];
194
+ for (const t of treeArray) measureDepth(t, 0);
195
+
196
+ // Build concise label: Root > [Child1, Child2 > [Grandchild]]
197
+ function label(node, d) {
198
+ if (!node) return '';
199
+ let s = node.name || `#${node.id}`;
200
+ const kids = (node.children || []);
201
+ if (kids.length && d < 3) {
202
+ s += ' > [' + kids.map(k => label(k, d + 1)).join(', ') + ']';
203
+ } else if (kids.length) {
204
+ s += ` > [... ${kids.length} children]`;
205
+ }
206
+ return s;
207
+ }
208
+ const labelStr = treeArray.map(t => label(t, 0)).join(', ');
209
+
210
+ const summary = `${totalNodes} component${totalNodes !== 1 ? 's' : ''}, max depth ${maxDepth}. ${labelStr}`;
211
+
212
+ return ok({ tree, summary });
213
+ }
214
+ );
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Tool 2 — what_dependency_graph
218
+ // ---------------------------------------------------------------------------
219
+
220
+ server.tool(
221
+ 'what_dependency_graph',
222
+ 'Get the reactive dependency graph showing which signals feed which effects. The unique debugging superpower of What Framework.',
223
+ {
224
+ signalId: z.number().optional().describe('Show graph starting from this signal'),
225
+ effectId: z.number().optional().describe('Show graph starting from this effect'),
226
+ direction: z.enum(['downstream', 'upstream', 'both']).optional().default('both')
227
+ .describe('downstream = who depends on this; upstream = what does this depend on (default: both)'),
228
+ },
229
+ async ({ signalId, effectId, direction }) => {
230
+ const { snapshot, err } = await freshSnapshot('what_dependency_graph');
231
+ if (err) return err;
232
+
233
+ const signals = snapshot.signals || [];
234
+ const effects = snapshot.effects || [];
235
+
236
+ // Build lookup maps
237
+ const signalMap = new Map(signals.map(s => [s.id, s]));
238
+ const effectMap = new Map(effects.map(e => [e.id, e]));
239
+
240
+ // Build edges: effect depends on signal => signal --triggers--> effect
241
+ const allEdges = [];
242
+ for (const eff of effects) {
243
+ const deps = eff.depSignalIds || eff.deps || [];
244
+ for (const sid of deps) {
245
+ allEdges.push({
246
+ from: { type: 'signal', id: sid },
247
+ to: { type: 'effect', id: eff.id },
248
+ relation: 'triggers',
249
+ });
250
+ }
251
+ }
252
+
253
+ // If a specific signal or effect is requested, filter the graph
254
+ let filteredEdges = allEdges;
255
+ let nodeIds = null; // Set of "type:id" strings we want to include
256
+
257
+ if (signalId != null) {
258
+ if (!signalMap.has(signalId)) {
259
+ return errorResponse(`Signal ${signalId} not found.`, [
260
+ 'Use what_signals to list available signal IDs.',
261
+ ]);
262
+ }
263
+ nodeIds = new Set();
264
+ nodeIds.add(`signal:${signalId}`);
265
+
266
+ if (direction === 'downstream' || direction === 'both') {
267
+ // Signals that this signal triggers (effects that read it)
268
+ for (const e of allEdges) {
269
+ if (e.from.type === 'signal' && e.from.id === signalId) {
270
+ nodeIds.add(`effect:${e.to.id}`);
271
+ }
272
+ }
273
+ }
274
+ if (direction === 'upstream' || direction === 'both') {
275
+ // Nothing upstream of a signal in the basic model
276
+ // (signals are the roots), but include for completeness
277
+ }
278
+
279
+ filteredEdges = allEdges.filter(e => {
280
+ return nodeIds.has(`${e.from.type}:${e.from.id}`) || nodeIds.has(`${e.to.type}:${e.to.id}`);
281
+ });
282
+ // Expand node set from filtered edges
283
+ for (const e of filteredEdges) {
284
+ nodeIds.add(`${e.from.type}:${e.from.id}`);
285
+ nodeIds.add(`${e.to.type}:${e.to.id}`);
286
+ }
287
+ }
288
+
289
+ if (effectId != null) {
290
+ if (!effectMap.has(effectId)) {
291
+ return errorResponse(`Effect ${effectId} not found.`, [
292
+ 'Use what_effects to list available effect IDs.',
293
+ ]);
294
+ }
295
+ nodeIds = nodeIds || new Set();
296
+ nodeIds.add(`effect:${effectId}`);
297
+
298
+ if (direction === 'upstream' || direction === 'both') {
299
+ // Signals that this effect depends on
300
+ const eff = effectMap.get(effectId);
301
+ const deps = eff.depSignalIds || eff.deps || [];
302
+ for (const sid of deps) {
303
+ nodeIds.add(`signal:${sid}`);
304
+ }
305
+ }
306
+ if (direction === 'downstream' || direction === 'both') {
307
+ // Effects don't directly trigger other things in the basic model
308
+ }
309
+
310
+ filteredEdges = allEdges.filter(e => {
311
+ return nodeIds.has(`${e.from.type}:${e.from.id}`) || nodeIds.has(`${e.to.type}:${e.to.id}`);
312
+ });
313
+ for (const e of filteredEdges) {
314
+ nodeIds.add(`${e.from.type}:${e.from.id}`);
315
+ nodeIds.add(`${e.to.type}:${e.to.id}`);
316
+ }
317
+ }
318
+
319
+ // Build nodes
320
+ const nodeSet = nodeIds || new Set([
321
+ ...signals.map(s => `signal:${s.id}`),
322
+ ...effects.map(e => `effect:${e.id}`),
323
+ ]);
324
+
325
+ const nodes = [];
326
+ for (const key of nodeSet) {
327
+ const [type, idStr] = key.split(':');
328
+ const id = Number(idStr);
329
+ if (type === 'signal') {
330
+ const s = signalMap.get(id);
331
+ nodes.push({ type: 'signal', id, name: s?.name || `signal_${id}`, value: s?.value });
332
+ } else {
333
+ const e = effectMap.get(id);
334
+ nodes.push({ type: 'effect', id, name: e?.name || `effect_${id}`, runCount: e?.runCount });
335
+ }
336
+ }
337
+
338
+ const signalNodes = nodes.filter(n => n.type === 'signal');
339
+ const effectNodes = nodes.filter(n => n.type === 'effect');
340
+ const summary = `${signalNodes.length} signal${signalNodes.length !== 1 ? 's' : ''}, ` +
341
+ `${effectNodes.length} effect${effectNodes.length !== 1 ? 's' : ''}, ` +
342
+ `${filteredEdges.length} edge${filteredEdges.length !== 1 ? 's' : ''}. ` +
343
+ (signalId != null ? `Focused on signal #${signalId}. ` : '') +
344
+ (effectId != null ? `Focused on effect #${effectId}. ` : '') +
345
+ `Direction: ${direction}.`;
346
+
347
+ return ok({ nodes, edges: filteredEdges, summary });
348
+ }
349
+ );
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Tool 3 — what_eval
353
+ // ---------------------------------------------------------------------------
354
+
355
+ server.tool(
356
+ 'what_eval',
357
+ 'Execute JavaScript in the browser context. Has access to window, document, __WHAT_DEVTOOLS__, and __WHAT_CORE__. Use for debugging scenarios not covered by other tools. Dev-only.',
358
+ {
359
+ code: z.string().describe('JavaScript code to execute in the browser. Return a value to see it in the response.'),
360
+ timeout: z.number().optional().default(5000).describe('Max execution time in ms (default: 5000, max: 30000)'),
361
+ },
362
+ async ({ code, timeout }) => {
363
+ if (!bridge.isConnected()) return noConnection('what_eval');
364
+
365
+ const clampedTimeout = Math.min(Math.max(timeout || 5000, 100), 30000);
366
+
367
+ try {
368
+ const result = await bridge.sendCommand('eval', { code }, clampedTimeout);
369
+ if (result.error) {
370
+ return errorResponse(result.error, [
371
+ 'Check your JavaScript code for syntax or runtime errors.',
372
+ result.stack ? `Stack trace: ${result.stack}` : null,
373
+ ].filter(Boolean));
374
+ }
375
+ const summary = `Executed in ${result.executionTime ?? '?'}ms. ` +
376
+ `Result type: ${result.type}. ` +
377
+ (typeof result.result === 'string'
378
+ ? `Value: "${result.result.substring(0, 100)}${result.result.length > 100 ? '...' : ''}"`
379
+ : `Value: ${JSON.stringify(result.result)?.substring(0, 120) ?? 'undefined'}`);
380
+ return ok({ ...result, summary });
381
+ } catch (e) {
382
+ return errorResponse(e.message, [
383
+ 'The browser may have disconnected or the code timed out.',
384
+ 'Try a simpler expression or increase the timeout.',
385
+ ]);
386
+ }
387
+ }
388
+ );
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Tool 4 — what_dom_inspect
392
+ // ---------------------------------------------------------------------------
393
+
394
+ server.tool(
395
+ 'what_dom_inspect',
396
+ 'Get the rendered DOM output of a component. Returns both structured tree and raw HTML.',
397
+ {
398
+ componentId: z.number().describe('Component ID to inspect (from what_components)'),
399
+ depth: z.number().optional().default(3).describe('Max DOM depth (default: 3)'),
400
+ },
401
+ async ({ componentId, depth }) => {
402
+ if (!bridge.isConnected()) return noConnection('what_dom_inspect');
403
+
404
+ try {
405
+ const result = await bridge.sendCommand('dom-inspect', { componentId, depth: depth ?? 3 });
406
+ if (result.error) {
407
+ return errorResponse(result.error, [
408
+ 'Use what_components to verify the component ID exists.',
409
+ 'The component may not have a DOM element (e.g., a context provider).',
410
+ ]);
411
+ }
412
+ const htmlPreview = (result.html || '').substring(0, 200);
413
+ const summary = `Component "${result.componentName || '?'}". ` +
414
+ `HTML (${(result.html || '').length} chars): ${htmlPreview}${(result.html || '').length > 200 ? '...' : ''}`;
415
+ return ok({ ...result, summary });
416
+ } catch (e) {
417
+ return errorResponse(e.message, [
418
+ 'The browser may have disconnected.',
419
+ 'Try what_components first to get valid component IDs.',
420
+ ]);
421
+ }
422
+ }
423
+ );
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Tool 5 — what_route
427
+ // ---------------------------------------------------------------------------
428
+
429
+ server.tool(
430
+ 'what_route',
431
+ 'Get current route information (path, params, query, matched route pattern)',
432
+ {},
433
+ async () => {
434
+ if (!bridge.isConnected()) return noConnection('what_route');
435
+
436
+ try {
437
+ const result = await bridge.sendCommand('get-route', {});
438
+ if (result.error) {
439
+ return errorResponse(result.error, [
440
+ 'Route info may not be available if the app does not use What Router.',
441
+ ]);
442
+ }
443
+ const params = result.params ? Object.entries(result.params).map(([k, v]) => `${k}=${v}`).join(', ') : '';
444
+ const query = result.query ? Object.entries(result.query).map(([k, v]) => `${k}=${v}`).join(', ') : '';
445
+ const summary = `Path: ${result.path || '/'}` +
446
+ (result.matchedRoute ? ` (pattern: ${result.matchedRoute})` : '') +
447
+ (params ? ` | Params: ${params}` : '') +
448
+ (query ? ` | Query: ${query}` : '') +
449
+ (result.hash ? ` | Hash: ${result.hash}` : '');
450
+ return ok({ ...result, summary });
451
+ } catch (e) {
452
+ return errorResponse(e.message, [
453
+ 'The browser may have disconnected.',
454
+ 'Route information falls back to window.location if What Router is not used.',
455
+ ]);
456
+ }
457
+ }
458
+ );
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Tool 6 — what_diagnose
462
+ // ---------------------------------------------------------------------------
463
+
464
+ server.tool(
465
+ 'what_diagnose',
466
+ 'Run a comprehensive diagnostic check on the app. Identifies errors, performance issues, and reactivity problems in one call.',
467
+ {
468
+ focus: z.enum(['errors', 'performance', 'reactivity', 'all']).optional().default('all')
469
+ .describe('What to focus on (default: all)'),
470
+ },
471
+ async ({ focus }) => {
472
+ const { snapshot, err } = await freshSnapshot('what_diagnose');
473
+ if (err) return err;
474
+
475
+ const signals = snapshot.signals || [];
476
+ const effects = snapshot.effects || [];
477
+ const components = snapshot.components || [];
478
+ const errors = bridge.getErrors();
479
+ const recentEvents = bridge.getEvents(Date.now() - 60_000); // last 60s
480
+
481
+ const issues = [];
482
+ const healthy = [];
483
+
484
+ // --- Error checks ---
485
+ if (focus === 'errors' || focus === 'all') {
486
+ if (errors.length > 0) {
487
+ issues.push({
488
+ severity: 'error',
489
+ category: 'errors',
490
+ message: `${errors.length} runtime error${errors.length !== 1 ? 's' : ''} captured.`,
491
+ details: errors.slice(-5).map(e => e.message || e.error || JSON.stringify(e)),
492
+ });
493
+ } else {
494
+ healthy.push({ category: 'errors', message: 'No runtime errors captured.' });
495
+ }
496
+ }
497
+
498
+ // --- Performance checks ---
499
+ if (focus === 'performance' || focus === 'all') {
500
+ const hotEffects = effects.filter(e => (e.runCount || 0) > 50);
501
+ if (hotEffects.length > 0) {
502
+ issues.push({
503
+ severity: 'warning',
504
+ category: 'performance',
505
+ message: `${hotEffects.length} effect${hotEffects.length !== 1 ? 's' : ''} with runCount > 50 (potential hot paths).`,
506
+ details: hotEffects.map(e => ({
507
+ id: e.id,
508
+ name: e.name,
509
+ runCount: e.runCount,
510
+ componentId: e.componentId,
511
+ })),
512
+ });
513
+ } else {
514
+ healthy.push({ category: 'performance', message: 'No hot effects detected (all runCount <= 50).' });
515
+ }
516
+
517
+ // Check for excessive recent events (>500 in 60s)
518
+ if (recentEvents.length > 500) {
519
+ issues.push({
520
+ severity: 'warning',
521
+ category: 'performance',
522
+ message: `High event volume: ${recentEvents.length} events in the last 60 seconds.`,
523
+ details: null,
524
+ });
525
+ } else {
526
+ healthy.push({ category: 'performance', message: `Event volume normal: ${recentEvents.length} events in last 60s.` });
527
+ }
528
+ }
529
+
530
+ // --- Reactivity checks ---
531
+ if (focus === 'reactivity' || focus === 'all') {
532
+ // Signals with no subscribers (no effect depends on them)
533
+ const subscribedSignalIds = new Set();
534
+ for (const eff of effects) {
535
+ for (const sid of (eff.depSignalIds || eff.deps || [])) {
536
+ subscribedSignalIds.add(sid);
537
+ }
538
+ }
539
+ const orphanSignals = signals.filter(s => !subscribedSignalIds.has(s.id));
540
+ if (orphanSignals.length > 0) {
541
+ issues.push({
542
+ severity: 'info',
543
+ category: 'reactivity',
544
+ message: `${orphanSignals.length} signal${orphanSignals.length !== 1 ? 's' : ''} with no effect subscribers.`,
545
+ details: orphanSignals.slice(0, 10).map(s => ({ id: s.id, name: s.name, value: s.value })),
546
+ });
547
+ } else if (signals.length > 0) {
548
+ healthy.push({ category: 'reactivity', message: 'All signals have at least one subscriber.' });
549
+ }
550
+
551
+ // Effects with no dependencies (may be intentional, but worth flagging)
552
+ const noDepsEffects = effects.filter(e => {
553
+ const deps = e.depSignalIds || e.deps || [];
554
+ return deps.length === 0;
555
+ });
556
+ if (noDepsEffects.length > 0) {
557
+ issues.push({
558
+ severity: 'info',
559
+ category: 'reactivity',
560
+ message: `${noDepsEffects.length} effect${noDepsEffects.length !== 1 ? 's' : ''} with no signal dependencies (may be intentional).`,
561
+ details: noDepsEffects.slice(0, 10).map(e => ({ id: e.id, name: e.name, runCount: e.runCount })),
562
+ });
563
+ } else if (effects.length > 0) {
564
+ healthy.push({ category: 'reactivity', message: 'All effects have signal dependencies.' });
565
+ }
566
+ }
567
+
568
+ const severity = issues.some(i => i.severity === 'error') ? 'error'
569
+ : issues.some(i => i.severity === 'warning') ? 'warning'
570
+ : issues.length > 0 ? 'info'
571
+ : 'healthy';
572
+
573
+ const summary = severity === 'healthy'
574
+ ? `All checks passed. ${signals.length} signals, ${effects.length} effects, ${components.length} components.`
575
+ : `${issues.length} issue${issues.length !== 1 ? 's' : ''} found (${severity}). ` +
576
+ `${healthy.length} check${healthy.length !== 1 ? 's' : ''} passed. ` +
577
+ `${signals.length} signals, ${effects.length} effects, ${components.length} components.`;
578
+
579
+ return ok({
580
+ focus,
581
+ severity,
582
+ issues,
583
+ healthy,
584
+ counts: {
585
+ signals: signals.length,
586
+ effects: effects.length,
587
+ components: components.length,
588
+ errors: errors.length,
589
+ recentEvents: recentEvents.length,
590
+ },
591
+ summary,
592
+ });
593
+ }
594
+ );
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Tool 7 — what_diff_snapshot
598
+ // ---------------------------------------------------------------------------
599
+
600
+ server.tool(
601
+ 'what_diff_snapshot',
602
+ 'Compare app state between two points in time. Call with action="save" to store a baseline, then action="diff" to see what changed.',
603
+ {
604
+ action: z.enum(['save', 'diff']).describe('save = store current state as baseline; diff = compare current state to saved baseline'),
605
+ },
606
+ async ({ action }) => {
607
+ if (!bridge.isConnected()) return noConnection('what_diff_snapshot');
608
+
609
+ if (action === 'save') {
610
+ // Refresh before saving so baseline is current
611
+ try {
612
+ await bridge.refreshSnapshot();
613
+ } catch {
614
+ // Use whatever we have
615
+ }
616
+ const saved = bridge.saveBaseline();
617
+ if (!saved) {
618
+ return errorResponse('No snapshot available to save as baseline.', [
619
+ 'Make sure the app has sent at least one snapshot.',
620
+ 'Try refreshing the page and calling save again.',
621
+ ]);
622
+ }
623
+ const snap = bridge.getBaseline();
624
+ const summary = `Baseline saved. ${(snap.signals || []).length} signals, ` +
625
+ `${(snap.effects || []).length} effects, ` +
626
+ `${(snap.components || []).length} components at ${new Date().toISOString()}.`;
627
+ return ok({
628
+ action: 'save',
629
+ savedAt: Date.now(),
630
+ signalCount: (snap.signals || []).length,
631
+ effectCount: (snap.effects || []).length,
632
+ componentCount: (snap.components || []).length,
633
+ summary,
634
+ });
635
+ }
636
+
637
+ // action === 'diff'
638
+ const baseline = bridge.getBaseline();
639
+ if (!baseline) {
640
+ return errorResponse('No baseline saved. Call with action="save" first.', [
641
+ 'Use what_diff_snapshot with action="save" to store a baseline.',
642
+ 'Then interact with the app and call action="diff" to see changes.',
643
+ ]);
644
+ }
645
+
646
+ // Get fresh current state
647
+ let current;
648
+ try {
649
+ current = await bridge.refreshSnapshot();
650
+ } catch {
651
+ current = bridge.getSnapshot();
652
+ }
653
+ if (!current) return noSnapshot('what_diff_snapshot');
654
+
655
+ // --- Diff signals ---
656
+ const baseSignals = new Map((baseline.signals || []).map(s => [s.id, s]));
657
+ const currSignals = new Map((current.signals || []).map(s => [s.id, s]));
658
+
659
+ const signalsChanged = [];
660
+ const signalsAdded = [];
661
+ const signalsRemoved = [];
662
+
663
+ for (const [id, curr] of currSignals) {
664
+ const base = baseSignals.get(id);
665
+ if (!base) {
666
+ signalsAdded.push({ id, name: curr.name, value: curr.value });
667
+ } else if (JSON.stringify(base.value) !== JSON.stringify(curr.value)) {
668
+ signalsChanged.push({
669
+ id,
670
+ name: curr.name,
671
+ previousValue: base.value,
672
+ currentValue: curr.value,
673
+ });
674
+ }
675
+ }
676
+ for (const [id, base] of baseSignals) {
677
+ if (!currSignals.has(id)) {
678
+ signalsRemoved.push({ id, name: base.name, lastValue: base.value });
679
+ }
680
+ }
681
+
682
+ // --- Diff effects ---
683
+ const baseEffects = new Map((baseline.effects || []).map(e => [e.id, e]));
684
+ const currEffects = new Map((current.effects || []).map(e => [e.id, e]));
685
+
686
+ const effectsTriggered = [];
687
+ const effectsAdded = [];
688
+ const effectsRemoved = [];
689
+
690
+ for (const [id, curr] of currEffects) {
691
+ const base = baseEffects.get(id);
692
+ if (!base) {
693
+ effectsAdded.push({ id, name: curr.name, runCount: curr.runCount });
694
+ } else if ((curr.runCount || 0) > (base.runCount || 0)) {
695
+ effectsTriggered.push({
696
+ id,
697
+ name: curr.name,
698
+ previousRunCount: base.runCount || 0,
699
+ currentRunCount: curr.runCount || 0,
700
+ delta: (curr.runCount || 0) - (base.runCount || 0),
701
+ });
702
+ }
703
+ }
704
+ for (const [id, base] of baseEffects) {
705
+ if (!currEffects.has(id)) {
706
+ effectsRemoved.push({ id, name: base.name });
707
+ }
708
+ }
709
+
710
+ // --- Diff components ---
711
+ const baseComps = new Set((baseline.components || []).map(c => c.id));
712
+ const currComps = new Set((current.components || []).map(c => c.id));
713
+ const componentsAdded = (current.components || []).filter(c => !baseComps.has(c.id)).map(c => ({ id: c.id, name: c.name }));
714
+ const componentsRemoved = (baseline.components || []).filter(c => !currComps.has(c.id)).map(c => ({ id: c.id, name: c.name }));
715
+
716
+ // --- Errors since baseline ---
717
+ const errorsNew = bridge.getErrors(baseline._savedAt || 0);
718
+
719
+ const totalChanges = signalsChanged.length + signalsAdded.length + signalsRemoved.length +
720
+ effectsTriggered.length + effectsAdded.length + effectsRemoved.length +
721
+ componentsAdded.length + componentsRemoved.length;
722
+
723
+ const parts = [];
724
+ if (signalsChanged.length) parts.push(`${signalsChanged.length} signal${signalsChanged.length !== 1 ? 's' : ''} changed`);
725
+ if (signalsAdded.length) parts.push(`${signalsAdded.length} signal${signalsAdded.length !== 1 ? 's' : ''} added`);
726
+ if (signalsRemoved.length) parts.push(`${signalsRemoved.length} signal${signalsRemoved.length !== 1 ? 's' : ''} removed`);
727
+ if (effectsTriggered.length) parts.push(`${effectsTriggered.length} effect${effectsTriggered.length !== 1 ? 's' : ''} re-ran`);
728
+ if (effectsAdded.length) parts.push(`${effectsAdded.length} effect${effectsAdded.length !== 1 ? 's' : ''} added`);
729
+ if (effectsRemoved.length) parts.push(`${effectsRemoved.length} effect${effectsRemoved.length !== 1 ? 's' : ''} removed`);
730
+ if (componentsAdded.length) parts.push(`${componentsAdded.length} component${componentsAdded.length !== 1 ? 's' : ''} mounted`);
731
+ if (componentsRemoved.length) parts.push(`${componentsRemoved.length} component${componentsRemoved.length !== 1 ? 's' : ''} unmounted`);
732
+ if (errorsNew.length) parts.push(`${errorsNew.length} new error${errorsNew.length !== 1 ? 's' : ''}`);
733
+
734
+ const summary = totalChanges === 0 && errorsNew.length === 0
735
+ ? 'No changes detected since baseline.'
736
+ : parts.join(', ') + '.';
737
+
738
+ return ok({
739
+ action: 'diff',
740
+ signalsChanged,
741
+ signalsAdded,
742
+ signalsRemoved,
743
+ effectsTriggered,
744
+ effectsAdded,
745
+ effectsRemoved,
746
+ componentsAdded,
747
+ componentsRemoved,
748
+ errorsNew: errorsNew.length,
749
+ totalChanges,
750
+ summary,
751
+ });
752
+ }
753
+ );
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // Tool 8 — what_navigate
757
+ // ---------------------------------------------------------------------------
758
+
759
+ server.tool(
760
+ 'what_navigate',
761
+ 'Navigate to a different route in the app',
762
+ {
763
+ path: z.string().describe('Path to navigate to (e.g. "/dashboard")'),
764
+ replace: z.boolean().optional().default(false).describe('Use replaceState instead of pushState (default: false)'),
765
+ },
766
+ async ({ path, replace }) => {
767
+ if (!bridge.isConnected()) return noConnection('what_navigate');
768
+
769
+ try {
770
+ const result = await bridge.sendCommand('navigate', { path, replace });
771
+ if (result.error) {
772
+ return errorResponse(result.error, [
773
+ 'Check that the path is valid.',
774
+ 'Ensure the app is using What Router or has history API access.',
775
+ ]);
776
+ }
777
+ const summary = `Navigated to "${result.navigatedTo || path}". ` +
778
+ `Current path: ${result.currentPath || '?'}. ` +
779
+ `Method: ${replace ? 'replaceState' : 'pushState'}.`;
780
+ return ok({ ...result, summary });
781
+ } catch (e) {
782
+ return errorResponse(e.message, [
783
+ 'The browser may have disconnected.',
784
+ 'Try what_connection_status to check connectivity.',
785
+ ]);
786
+ }
787
+ }
788
+ );
789
+ }