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.
- package/README.md +223 -0
- package/package.json +33 -0
- package/src/bridge.js +171 -0
- package/src/client-commands.js +206 -0
- package/src/client.js +283 -0
- package/src/index.js +637 -0
- package/src/tools-agent.js +818 -0
- package/src/tools-extended.js +789 -0
- package/src/tools.js +670 -0
- package/src/vite-plugin.js +24 -0
|
@@ -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
|
+
}
|