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/src/tools.js ADDED
@@ -0,0 +1,670 @@
1
+ /**
2
+ * MCP tool definitions and handlers for what-devtools-mcp.
3
+ * 10 tools: 7 read, 2 write, 1 observe.
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ export function registerTools(server, bridge) {
9
+ // --- Helpers ---
10
+
11
+ /** Build a signalId -> name lookup map from any snapshot */
12
+ function buildSignalNameMap(snapshot) {
13
+ const map = {};
14
+ for (const s of snapshot?.signals || []) map[s.id] = s.name;
15
+ return map;
16
+ }
17
+
18
+ /** Build a compact component tree string like "App > [Header, Main > [Counter, Form]]" */
19
+ function buildComponentTreeSummary(components) {
20
+ if (!components || components.length === 0) return { tree: '(empty)', depth: 0 };
21
+ // Attempt to build tree from parent references; fallback to flat list
22
+ const byId = {};
23
+ const roots = [];
24
+ for (const c of components) {
25
+ byId[c.id] = { ...c, children: [] };
26
+ }
27
+ for (const c of components) {
28
+ if (c.parentId != null && byId[c.parentId]) {
29
+ byId[c.parentId].children.push(byId[c.id]);
30
+ } else {
31
+ roots.push(byId[c.id]);
32
+ }
33
+ }
34
+ function summarize(node, depth) {
35
+ if (depth > 5) return '...';
36
+ const name = node.name || `component_${node.id}`;
37
+ if (node.children.length === 0) return name;
38
+ const kids = node.children.map(c => summarize(c, depth + 1)).join(', ');
39
+ return `${name} > [${kids}]`;
40
+ }
41
+ // Compute max depth
42
+ function maxDepth(node, d) {
43
+ if (node.children.length === 0) return d;
44
+ return Math.max(...node.children.map(c => maxDepth(c, d + 1)));
45
+ }
46
+ const depth = roots.length > 0 ? Math.max(...roots.map(r => maxDepth(r, 1))) : 0;
47
+ const tree = roots.map(r => summarize(r, 0)).join(', ');
48
+ return { tree, depth };
49
+ }
50
+
51
+ // --- Read Tools ---
52
+
53
+ server.tool(
54
+ 'what_connection_status',
55
+ 'Check if a What Framework app is connected via WebSocket',
56
+ {},
57
+ async () => {
58
+ const connected = bridge.isConnected();
59
+ const snapshot = bridge.getSnapshot();
60
+ const signalCount = snapshot?.signals?.length || 0;
61
+ const effectCount = snapshot?.effects?.length || 0;
62
+ const componentCount = snapshot?.components?.length || 0;
63
+
64
+ let summary;
65
+ if (!connected) {
66
+ summary = 'No browser connected. Start your app with the what-devtools-mcp Vite plugin and refresh the page.';
67
+ } else if (!snapshot) {
68
+ summary = 'Browser connected but no snapshot received yet. Try refreshing the page.';
69
+ } else {
70
+ summary = `Connected. App has ${signalCount} signals, ${effectCount} effects, ${componentCount} components.`;
71
+ }
72
+
73
+ const result = {
74
+ summary,
75
+ connected,
76
+ hasSnapshot: snapshot !== null,
77
+ signalCount,
78
+ effectCount,
79
+ componentCount,
80
+ };
81
+
82
+ if (!connected) {
83
+ result.nextSteps = [
84
+ 'Make sure your app is running with the what-devtools-mcp Vite plugin',
85
+ 'Check that the MCP bridge server is running (npx what-devtools-mcp)',
86
+ 'Try refreshing the browser page',
87
+ ];
88
+ }
89
+
90
+ return {
91
+ content: [{
92
+ type: 'text',
93
+ text: JSON.stringify(result, null, 2),
94
+ }],
95
+ };
96
+ }
97
+ );
98
+
99
+ server.tool(
100
+ 'what_signals',
101
+ 'List all reactive signals with current values. Filter by name regex or ID.',
102
+ {
103
+ filter: z.string().optional().describe('Regex to filter signal names (ignored if id is set)'),
104
+ id: z.number().optional().describe('Get a specific signal by ID (takes precedence over filter)'),
105
+ },
106
+ async ({ filter, id }) => {
107
+ if (!bridge.isConnected()) {
108
+ return noConnection('what_signals');
109
+ }
110
+ const snapshot = await bridge.getOrRefreshSnapshot();
111
+ if (!snapshot) return noSnapshot();
112
+
113
+ let signals = snapshot.signals || [];
114
+ const totalCount = signals.length;
115
+
116
+ if (id != null) {
117
+ signals = signals.filter(s => s.id === id);
118
+ } else if (filter) {
119
+ try {
120
+ const re = new RegExp(filter, 'i');
121
+ signals = signals.filter(s => re.test(s.name));
122
+ } catch {
123
+ return error(`Invalid regex: ${filter}`);
124
+ }
125
+ }
126
+
127
+ // Build summary
128
+ const valuePreviews = signals.slice(0, 5).map(s => {
129
+ const val = typeof s.value === 'string' ? `'${s.value}'` : JSON.stringify(s.value);
130
+ const truncated = val && val.length > 40 ? val.slice(0, 37) + '...' : val;
131
+ return `${s.name}=${truncated}`;
132
+ });
133
+ const filterNote = id != null ? ` 1 matched id=${id}.` : filter ? ` ${signals.length} match filter '${filter}'.` : '';
134
+ const valuesNote = valuePreviews.length > 0 ? ` Values: ${valuePreviews.join(', ')}` : '';
135
+ const moreNote = signals.length > 5 ? `, ... (${signals.length - 5} more)` : '';
136
+ const summary = `${totalCount} signals total.${filterNote}${valuesNote}${moreNote}`;
137
+
138
+ return {
139
+ content: [{
140
+ type: 'text',
141
+ text: JSON.stringify({ summary, count: signals.length, signals }, null, 2),
142
+ }],
143
+ };
144
+ }
145
+ );
146
+
147
+ server.tool(
148
+ 'what_effects',
149
+ 'List all active effects with dependency signal IDs, run counts, and timing',
150
+ {
151
+ minRunCount: z.number().optional().describe('Only show effects with runCount >= this value'),
152
+ filter: z.string().optional().describe('Regex pattern to filter effect names'),
153
+ depSignalId: z.number().optional().describe('Find effects that depend on this signal ID'),
154
+ },
155
+ async ({ minRunCount, filter, depSignalId }) => {
156
+ if (!bridge.isConnected()) return noConnection('what_effects');
157
+ const snapshot = await bridge.getOrRefreshSnapshot();
158
+ if (!snapshot) return noSnapshot();
159
+
160
+ let effects = snapshot.effects || [];
161
+ const totalCount = effects.length;
162
+
163
+ if (minRunCount != null) {
164
+ effects = effects.filter(e => (e.runCount || 0) >= minRunCount);
165
+ }
166
+
167
+ if (filter) {
168
+ try {
169
+ const re = new RegExp(filter, 'i');
170
+ effects = effects.filter(e => re.test(e.name || ''));
171
+ } catch {
172
+ return error(`Invalid regex: ${filter}`);
173
+ }
174
+ }
175
+
176
+ if (depSignalId != null) {
177
+ effects = effects.filter(e => (e.depSignalIds || []).includes(depSignalId));
178
+ }
179
+
180
+ // Resolve dependency signal IDs to names
181
+ const signalNames = buildSignalNameMap(snapshot);
182
+ effects = effects.map(e => ({
183
+ ...e,
184
+ depSignalNames: (e.depSignalIds || []).map(sid => signalNames[sid] || `signal_${sid}`),
185
+ }));
186
+
187
+ // Build summary
188
+ const hotEffects = effects.filter(e => (e.runCount || 0) >= 50);
189
+ const hotNote = hotEffects.length > 0
190
+ ? ` ${hotEffects.length} have run 50+ times (${hotEffects.slice(0, 3).map(e => e.name || `effect_${e.id}`).join(', ')}) — may indicate hot paths.`
191
+ : '';
192
+ const summary = `${totalCount} effects tracked. ${effects.length} returned after filters.${hotNote}`;
193
+
194
+ return {
195
+ content: [{
196
+ type: 'text',
197
+ text: JSON.stringify({ summary, count: effects.length, effects }, null, 2),
198
+ }],
199
+ };
200
+ }
201
+ );
202
+
203
+ server.tool(
204
+ 'what_components',
205
+ 'List all mounted What Framework components',
206
+ {
207
+ filter: z.string().optional().describe('Regex pattern to filter component names'),
208
+ },
209
+ async ({ filter }) => {
210
+ if (!bridge.isConnected()) return noConnection('what_components');
211
+ const snapshot = await bridge.getOrRefreshSnapshot();
212
+ if (!snapshot) return noSnapshot();
213
+
214
+ let components = snapshot.components || [];
215
+ const totalCount = components.length;
216
+
217
+ if (filter) {
218
+ try {
219
+ const re = new RegExp(filter, 'i');
220
+ components = components.filter(c => re.test(c.name || ''));
221
+ } catch {
222
+ return error(`Invalid regex: ${filter}`);
223
+ }
224
+ }
225
+
226
+ // Build tree summary
227
+ const { tree, depth } = buildComponentTreeSummary(components);
228
+ const summary = `${totalCount} components mounted. Tree depth: ${depth}. Root: ${tree}`;
229
+
230
+ return {
231
+ content: [{
232
+ type: 'text',
233
+ text: JSON.stringify({
234
+ summary,
235
+ count: components.length,
236
+ components,
237
+ }, null, 2),
238
+ }],
239
+ };
240
+ }
241
+ );
242
+
243
+ server.tool(
244
+ 'what_snapshot',
245
+ 'Get a full state snapshot (signals, effects, components, errors). Refreshes from browser. Use diff=true to get only changes since last snapshot.',
246
+ {
247
+ maxSignals: z.number().optional().default(100).describe('Max signals to return (default: 100)'),
248
+ maxEffects: z.number().optional().default(100).describe('Max effects to return (default: 100)'),
249
+ diff: z.boolean().optional().default(false).describe('If true, returns only changes since the last snapshot call (default: false)'),
250
+ },
251
+ async ({ maxSignals, maxEffects, diff }) => {
252
+ if (!bridge.isConnected()) return noConnection('what_snapshot');
253
+
254
+ // Store previous snapshot for diff mode
255
+ const previousSnapshot = diff ? bridge.getSnapshot() : null;
256
+
257
+ const snapshot = await bridge.getOrRefreshSnapshot();
258
+ if (!snapshot) return noSnapshot();
259
+
260
+ const allSignals = snapshot.signals || [];
261
+ const allEffects = snapshot.effects || [];
262
+ const allComponents = snapshot.components || [];
263
+ const allErrors = bridge.getErrors();
264
+
265
+ // --- Diff mode ---
266
+ if (diff && previousSnapshot) {
267
+ const prevSignals = new Map((previousSnapshot.signals || []).map(s => [s.id, s]));
268
+ const prevEffects = new Map((previousSnapshot.effects || []).map(e => [e.id, e]));
269
+ const prevComps = new Set((previousSnapshot.components || []).map(c => c.id));
270
+
271
+ const signalsChanged = [];
272
+ const signalsAdded = [];
273
+ for (const sig of allSignals) {
274
+ const prev = prevSignals.get(sig.id);
275
+ if (!prev) {
276
+ signalsAdded.push(sig);
277
+ } else if (JSON.stringify(prev.value) !== JSON.stringify(sig.value)) {
278
+ signalsChanged.push({ ...sig, previousValue: prev.value });
279
+ }
280
+ }
281
+
282
+ const signalsRemoved = (previousSnapshot.signals || [])
283
+ .filter(s => !allSignals.find(cur => cur.id === s.id))
284
+ .map(s => ({ id: s.id, name: s.name }));
285
+
286
+ const effectsTriggered = allEffects
287
+ .filter(e => {
288
+ const prev = prevEffects.get(e.id);
289
+ return prev && (e.runCount || 0) > (prev.runCount || 0);
290
+ })
291
+ .map(e => ({
292
+ ...e,
293
+ delta: (e.runCount || 0) - (prevEffects.get(e.id)?.runCount || 0),
294
+ }));
295
+
296
+ const componentsAdded = allComponents.filter(c => !prevComps.has(c.id));
297
+ const componentsRemoved = (previousSnapshot.components || [])
298
+ .filter(c => !allComponents.find(cur => cur.id === c.id));
299
+
300
+ const totalChanges = signalsChanged.length + signalsAdded.length + signalsRemoved.length +
301
+ effectsTriggered.length + componentsAdded.length + componentsRemoved.length;
302
+
303
+ const parts = [];
304
+ if (signalsChanged.length) parts.push(`${signalsChanged.length} signal(s) changed`);
305
+ if (signalsAdded.length) parts.push(`${signalsAdded.length} signal(s) added`);
306
+ if (signalsRemoved.length) parts.push(`${signalsRemoved.length} signal(s) removed`);
307
+ if (effectsTriggered.length) parts.push(`${effectsTriggered.length} effect(s) re-ran`);
308
+ if (componentsAdded.length) parts.push(`${componentsAdded.length} component(s) mounted`);
309
+ if (componentsRemoved.length) parts.push(`${componentsRemoved.length} component(s) unmounted`);
310
+
311
+ const diffSummary = totalChanges === 0
312
+ ? 'No changes since last snapshot.'
313
+ : `${totalChanges} changes: ${parts.join(', ')}.`;
314
+
315
+ return {
316
+ content: [{
317
+ type: 'text',
318
+ text: JSON.stringify({
319
+ mode: 'diff',
320
+ summary: diffSummary,
321
+ totalChanges,
322
+ signalsChanged,
323
+ signalsAdded,
324
+ signalsRemoved,
325
+ effectsTriggered,
326
+ componentsAdded,
327
+ componentsRemoved,
328
+ }, null, 2),
329
+ }],
330
+ };
331
+ }
332
+
333
+ // --- Full snapshot mode ---
334
+
335
+ // Detect hot effects
336
+ const hotEffects = allEffects
337
+ .filter(e => (e.runCount || 0) >= 50)
338
+ .map(e => ({ id: e.id, name: e.name, runCount: e.runCount }));
339
+
340
+ const summaryObj = {
341
+ signals: allSignals.length,
342
+ effects: allEffects.length,
343
+ components: allComponents.length,
344
+ errors: allErrors.length,
345
+ hotEffects,
346
+ };
347
+
348
+ // Truncation
349
+ const truncatedSignals = allSignals.length > maxSignals;
350
+ const truncatedEffects = allEffects.length > maxEffects;
351
+ const signals = truncatedSignals ? allSignals.slice(0, maxSignals) : allSignals;
352
+ const effects = truncatedEffects ? allEffects.slice(0, maxEffects) : allEffects;
353
+
354
+ const result = {
355
+ mode: 'full',
356
+ summary: summaryObj,
357
+ signals,
358
+ effects,
359
+ components: allComponents,
360
+ };
361
+
362
+ if (truncatedSignals || truncatedEffects) {
363
+ result.truncated = true;
364
+ result.totalCounts = {
365
+ signals: allSignals.length,
366
+ effects: allEffects.length,
367
+ };
368
+ }
369
+
370
+ return {
371
+ content: [{
372
+ type: 'text',
373
+ text: JSON.stringify(result, null, 2),
374
+ }],
375
+ };
376
+ }
377
+ );
378
+
379
+ server.tool(
380
+ 'what_errors',
381
+ 'Get captured runtime errors with structured classification, severity, and actionable suggestions. Filter by timestamp or severity.',
382
+ {
383
+ since: z.number().optional().describe('Only errors after this Unix timestamp (ms)'),
384
+ severity: z.enum(['error', 'warning', 'all']).optional().default('all').describe('Filter by severity (default: all)'),
385
+ },
386
+ async ({ since, severity }) => {
387
+ if (!bridge.isConnected()) return noConnection('what_errors');
388
+ let errors = bridge.getErrors(since);
389
+
390
+ // Classify each error with structured codes and suggestions
391
+ const classified = errors.map((err, idx) => {
392
+ const msg = err.message || err.error || '';
393
+ let classification = {
394
+ id: `err_${idx}`,
395
+ severity: 'error',
396
+ code: 'ERR_RUNTIME',
397
+ message: msg,
398
+ timestamp: err.timestamp,
399
+ file: err.file || null,
400
+ line: err.line || null,
401
+ component: err.component || null,
402
+ suggestion: 'Check the stack trace and component context for more details.',
403
+ codeExample: null,
404
+ };
405
+
406
+ // Classify by pattern matching
407
+ if (msg.includes('infinite effect loop') || msg.includes('25 iterations')) {
408
+ classification.code = 'ERR_INFINITE_EFFECT';
409
+ classification.severity = 'error';
410
+ classification.suggestion = 'An effect reads and writes the same signal. Use untrack() for the read, or restructure into separate effects.';
411
+ classification.codeExample = 'effect(() => { count(untrack(count) + 1); });';
412
+ } else if (msg.includes('hydration') || msg.includes('Hydration')) {
413
+ classification.code = 'ERR_HYDRATION_MISMATCH';
414
+ classification.severity = 'error';
415
+ classification.suggestion = 'Server and client HTML differ. Avoid browser APIs in initial render. Use onMount() for client-only code.';
416
+ } else if (msg.includes('Signal.set() called inside a computed')) {
417
+ classification.code = 'ERR_SIGNAL_WRITE_IN_RENDER';
418
+ classification.severity = 'error';
419
+ classification.suggestion = 'Move signal writes into event handlers or effects. Component body should only read signals.';
420
+ } else if (msg.includes('not a function') || msg.includes('is not defined')) {
421
+ classification.code = 'ERR_IMPORT_ERROR';
422
+ classification.severity = 'error';
423
+ classification.suggestion = 'Check that the import name matches a valid what-framework export. Use what_fix for the full API list.';
424
+ }
425
+
426
+ // Copy extra fields from original error
427
+ if (err.effectName) classification.effect = err.effectName;
428
+ if (err.effect) classification.effect = classification.effect || err.effect;
429
+ if (err.stack) classification.stack = err.stack;
430
+
431
+ return classification;
432
+ });
433
+
434
+ // Filter by severity
435
+ let filtered = classified;
436
+ if (severity && severity !== 'all') {
437
+ filtered = classified.filter(e => e.severity === severity);
438
+ }
439
+
440
+ // Build summary
441
+ let summary;
442
+ if (filtered.length === 0) {
443
+ summary = 'No errors captured.';
444
+ } else {
445
+ const mostRecent = filtered[filtered.length - 1];
446
+ const ageMs = Date.now() - (mostRecent.timestamp || 0);
447
+ const ageSec = Math.round(ageMs / 1000);
448
+ const ageStr = ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
449
+
450
+ // Group by code
451
+ const codeCounts = {};
452
+ for (const e of filtered) {
453
+ codeCounts[e.code] = (codeCounts[e.code] || 0) + 1;
454
+ }
455
+ const breakdown = Object.entries(codeCounts).map(([code, count]) => `${count} ${code}`).join(', ');
456
+
457
+ summary = `${filtered.length} errors captured. Breakdown: ${breakdown}. Most recent: ${mostRecent.code} (${ageStr}).`;
458
+ }
459
+
460
+ return {
461
+ content: [{
462
+ type: 'text',
463
+ text: JSON.stringify({
464
+ summary,
465
+ count: filtered.length,
466
+ errors: filtered,
467
+ nextSteps: [
468
+ 'Use what_fix with the error code for detailed diagnosis and fix examples',
469
+ 'Use what_signals to check signal values referenced in the error',
470
+ 'Use what_effects to inspect the failing effect\'s dependencies',
471
+ 'Use what_lint to scan your code for common patterns that cause these errors',
472
+ ],
473
+ }, null, 2),
474
+ }],
475
+ };
476
+ }
477
+ );
478
+
479
+ server.tool(
480
+ 'what_cache',
481
+ 'Inspect SWR/useQuery cache entries from the running app',
482
+ {
483
+ key: z.string().optional().describe('Filter cache entries by key (substring match)'),
484
+ },
485
+ async ({ key }) => {
486
+ if (!bridge.isConnected()) return noConnection('what_cache');
487
+ try {
488
+ let cache = await bridge.getCacheSnapshot();
489
+ const entries = Array.isArray(cache) ? cache : [];
490
+
491
+ let filtered = entries;
492
+ if (key) {
493
+ filtered = entries.filter(e => (e.key || '').includes(key));
494
+ }
495
+
496
+ // Build summary
497
+ const staleEntries = filtered.filter(e => {
498
+ if (!e.timestamp) return false;
499
+ return Date.now() - e.timestamp > 30000;
500
+ });
501
+ const keys = filtered.slice(0, 5).map(e => e.key).filter(Boolean);
502
+ const moreNote = filtered.length > 5 ? `, ... (${filtered.length - 5} more)` : '';
503
+ const staleNote = staleEntries.length > 0 ? ` ${staleEntries.length} stale (> 30s old).` : '';
504
+ const keyNote = keys.length > 0 ? ` Keys: ${keys.join(', ')}${moreNote}` : '';
505
+ const summary = `${filtered.length} cache entries.${staleNote}${keyNote}`;
506
+
507
+ return {
508
+ content: [{
509
+ type: 'text',
510
+ text: JSON.stringify({ summary, count: filtered.length, entries: filtered }, null, 2),
511
+ }],
512
+ };
513
+ } catch (e) {
514
+ return error(e.message);
515
+ }
516
+ }
517
+ );
518
+
519
+ // --- Write Tools ---
520
+
521
+ server.tool(
522
+ 'what_set_signal',
523
+ 'Set a signal value in the running app. Returns previous and new values.',
524
+ {
525
+ signalId: z.number().describe('The signal ID to update (from what_signals)'),
526
+ value: z.any().describe('The new value to set (JSON-compatible)'),
527
+ },
528
+ async ({ signalId, value }) => {
529
+ if (!bridge.isConnected()) return noConnection('what_set_signal');
530
+ try {
531
+ const result = await bridge.sendCommand('set-signal', { signalId, value });
532
+ if (result.error) return error(result.error);
533
+
534
+ const summary = `Signal ${signalId} updated. Previous: ${JSON.stringify(result.previousValue)}, New: ${JSON.stringify(result.newValue ?? value)}`;
535
+
536
+ return {
537
+ content: [{
538
+ type: 'text',
539
+ text: JSON.stringify({ summary, success: true, signalId, ...result }, null, 2),
540
+ }],
541
+ };
542
+ } catch (e) {
543
+ return error(e.message);
544
+ }
545
+ }
546
+ );
547
+
548
+ server.tool(
549
+ 'what_invalidate_cache',
550
+ 'Force-refresh a cache key in the running app',
551
+ {
552
+ key: z.string().describe('The cache key to invalidate'),
553
+ },
554
+ async ({ key }) => {
555
+ if (!bridge.isConnected()) return noConnection('what_invalidate_cache');
556
+ try {
557
+ const result = await bridge.sendCommand('invalidate-cache', { key });
558
+ if (result.error) return error(result.error);
559
+
560
+ const summary = `Cache key '${key}' invalidated successfully.`;
561
+
562
+ return {
563
+ content: [{
564
+ type: 'text',
565
+ text: JSON.stringify({ summary, success: true, key }, null, 2),
566
+ }],
567
+ };
568
+ } catch (e) {
569
+ return error(e.message);
570
+ }
571
+ }
572
+ );
573
+
574
+ // --- Observe Tool ---
575
+
576
+ server.tool(
577
+ 'what_watch',
578
+ 'Watch for reactive changes over a time window. Blocks for the specified duration, then returns collected events. Event types: signal:created, signal:updated, signal:disposed, effect:created, effect:run, effect:disposed, error:captured, component:mounted, component:unmounted',
579
+ {
580
+ duration: z.number().optional().default(3000).describe('Duration in ms to collect events (default: 3000, max: 30000)'),
581
+ filter: z.string().optional().describe('Regex to filter event names (e.g. "signal:updated")'),
582
+ },
583
+ async ({ duration, filter }) => {
584
+ if (!bridge.isConnected()) return noConnection('what_watch');
585
+
586
+ const ms = Math.min(Math.max(duration || 3000, 100), 30000);
587
+ const startTime = Date.now();
588
+
589
+ // Wait for the duration
590
+ await new Promise(resolve => setTimeout(resolve, ms));
591
+
592
+ // Collect events that occurred during the window
593
+ let events = bridge.getEvents(startTime);
594
+
595
+ if (filter) {
596
+ try {
597
+ const re = new RegExp(filter, 'i');
598
+ events = events.filter(e => re.test(e.event));
599
+ } catch {
600
+ return error(`Invalid regex: ${filter}`);
601
+ }
602
+ }
603
+
604
+ // Build event type breakdown
605
+ const typeCounts = {};
606
+ for (const e of events) {
607
+ typeCounts[e.event] = (typeCounts[e.event] || 0) + 1;
608
+ }
609
+ const breakdown = Object.entries(typeCounts).map(([type, count]) => `${count} ${type}`).join(', ');
610
+ const summary = `Collected ${events.length} events in ${ms}ms.${breakdown ? ' ' + breakdown + '.' : ' No events observed.'}`;
611
+
612
+ return {
613
+ content: [{
614
+ type: 'text',
615
+ text: JSON.stringify({
616
+ summary,
617
+ duration: ms,
618
+ eventCount: events.length,
619
+ typeCounts,
620
+ events: events.slice(0, 200), // cap to prevent huge payloads
621
+ }, null, 2),
622
+ }],
623
+ };
624
+ }
625
+ );
626
+ }
627
+
628
+ // Helper responses
629
+ function noConnection(tool) {
630
+ return {
631
+ content: [{
632
+ type: 'text',
633
+ text: JSON.stringify({
634
+ error: 'No browser connected',
635
+ hint: `No What Framework app is connected to the devtools bridge. Make sure your app is running with the what-devtools-mcp Vite plugin enabled, or manually call connectDevToolsMCP() in your app.`,
636
+ tool,
637
+ nextSteps: [
638
+ 'Make sure your app is running with the what-devtools-mcp Vite plugin',
639
+ 'Check that the MCP bridge server is running (npx what-devtools-mcp)',
640
+ 'Try refreshing the browser page',
641
+ ],
642
+ }, null, 2),
643
+ }],
644
+ isError: true,
645
+ };
646
+ }
647
+
648
+ function noSnapshot() {
649
+ return {
650
+ content: [{
651
+ type: 'text',
652
+ text: JSON.stringify({
653
+ error: 'No snapshot available',
654
+ hint: 'The browser is connected but has not sent a state snapshot yet. Try refreshing the page.',
655
+ nextSteps: [
656
+ 'Refresh the browser page to trigger a new snapshot',
657
+ 'Check the browser console for connection errors',
658
+ ],
659
+ }, null, 2),
660
+ }],
661
+ isError: true,
662
+ };
663
+ }
664
+
665
+ function error(message) {
666
+ return {
667
+ content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
668
+ isError: true,
669
+ };
670
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Vite plugin to auto-inject what-devtools-mcp client into dev server.
3
+ * Only active during `vite dev` (apply: 'serve').
4
+ */
5
+
6
+ export default function whatDevToolsMCP({ port = 9229 } = {}) {
7
+ return {
8
+ name: 'what-devtools-mcp',
9
+ apply: 'serve',
10
+ transformIndexHtml(html) {
11
+ return html.replace(
12
+ '</body>',
13
+ `<script type="module">
14
+ import * as core from 'what-core';
15
+ import { installDevTools } from 'what-devtools';
16
+ import { connectDevToolsMCP } from 'what-devtools-mcp/client';
17
+ installDevTools(core);
18
+ connectDevToolsMCP({ port: ${port} });
19
+ </script>
20
+ </body>`
21
+ );
22
+ },
23
+ };
24
+ }