what-devtools-mcp 0.6.0 → 0.6.2

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,420 @@
1
+ /**
2
+ * Interaction MCP tools for what-devtools-mcp.
3
+ * 6 tools: click, fill, interact, assert, wait, enhanced page_map.
4
+ *
5
+ * These are the "Playwright-killer" tools — semantic page interaction
6
+ * that leverages What Framework's component/signal knowledge.
7
+ * Every action reports WHAT CHANGED (signals, components, effects),
8
+ * not just "I clicked the element."
9
+ */
10
+
11
+ import { z } from 'zod';
12
+
13
+ export function registerInteractionTools(server, bridge) {
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helper responses
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function noConnection(tool) {
20
+ return {
21
+ content: [{
22
+ type: 'text',
23
+ text: JSON.stringify({
24
+ error: 'No browser connected',
25
+ summary: `Cannot reach browser for ${tool}.`,
26
+ nextSteps: [
27
+ 'Ensure your What Framework app is running with the devtools-mcp Vite plugin enabled.',
28
+ 'Or call connectDevToolsMCP() manually in the browser console.',
29
+ ],
30
+ }, null, 2),
31
+ }],
32
+ isError: true,
33
+ };
34
+ }
35
+
36
+ function errorResponse(message, nextSteps) {
37
+ return {
38
+ content: [{
39
+ type: 'text',
40
+ text: JSON.stringify({
41
+ error: message,
42
+ summary: message,
43
+ nextSteps: nextSteps || ['Check the arguments and try again.'],
44
+ }, null, 2),
45
+ }],
46
+ isError: true,
47
+ };
48
+ }
49
+
50
+ function ok(data) {
51
+ return {
52
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
53
+ };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Tool 1 — what_click
58
+ // ---------------------------------------------------------------------------
59
+
60
+ server.tool(
61
+ 'what_click',
62
+ 'Click any interactive element by text, ARIA label, test-id, component ID, or role. Returns what changed: signal updates, component mounts/unmounts, navigation. More powerful than Playwright — uses semantic matching, not CSS selectors.',
63
+ {
64
+ text: z.string().optional().describe('Click element with this text (button text, link text). Matches interactives first, then any element.'),
65
+ ariaLabel: z.string().optional().describe('Click element with this aria-label attribute'),
66
+ testId: z.string().optional().describe('Click element with this data-testid attribute'),
67
+ componentId: z.number().optional().describe('Scope the search to this component (from what_components)'),
68
+ role: z.string().optional().describe('Click element with this ARIA role (e.g. "button", "link", "tab")'),
69
+ index: z.number().optional().describe('If multiple elements match, click the Nth one (0-based, default: 0)'),
70
+ },
71
+ async ({ text, ariaLabel, testId, componentId, role, index }) => {
72
+ if (!bridge.isConnected()) return noConnection('what_click');
73
+
74
+ // At least one selector required
75
+ if (!text && !ariaLabel && !testId && !role && componentId == null) {
76
+ return errorResponse(
77
+ 'No selector provided. Specify at least one of: text, ariaLabel, testId, role, componentId.',
78
+ ['Use what_page_map to see available interactive elements.']
79
+ );
80
+ }
81
+
82
+ try {
83
+ const result = await bridge.sendCommand('click', {
84
+ text, ariaLabel, testId, componentId, role, index,
85
+ _prevPath: undefined, // Set by the browser handler
86
+ }, 10000);
87
+
88
+ if (result.error) {
89
+ return errorResponse(result.error, [
90
+ result.suggestion || 'Use what_page_map to see available interactive elements and their labels.',
91
+ ]);
92
+ }
93
+
94
+ // Build summary
95
+ const parts = [];
96
+ if (result.clicked) parts.push(`Clicked ${result.element?.tag || 'element'} (${result.matched})`);
97
+ if (result.changes?.signalsChanged?.length) {
98
+ const sigChanges = result.changes.signalsChanged.map(s =>
99
+ `${s.name}: ${JSON.stringify(s.previousValue)} -> ${JSON.stringify(s.currentValue)}`
100
+ );
101
+ parts.push(`Signals changed: ${sigChanges.join(', ')}`);
102
+ }
103
+ if (result.changes?.componentsAdded?.length) {
104
+ parts.push(`Components mounted: ${result.changes.componentsAdded.map(c => c.name).join(', ')}`);
105
+ }
106
+ if (result.changes?.componentsRemoved?.length) {
107
+ parts.push(`Components unmounted: ${result.changes.componentsRemoved.map(c => c.id).join(', ')}`);
108
+ }
109
+ if (result.navigated) parts.push(`Navigated to ${result.currentPath}`);
110
+
111
+ const summary = parts.join('. ') + '.';
112
+
113
+ return ok({ summary, ...result });
114
+ } catch (e) {
115
+ return errorResponse(`Click failed: ${e.message}`, [
116
+ 'Check what_connection_status.',
117
+ 'Use what_page_map to verify the element exists.',
118
+ ]);
119
+ }
120
+ }
121
+ );
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Tool 2 — what_fill
125
+ // ---------------------------------------------------------------------------
126
+
127
+ server.tool(
128
+ 'what_fill',
129
+ 'Fill form inputs by label, name, or placeholder. Can fill single fields or all inputs in a component at once. Returns validation state and signal changes.',
130
+ {
131
+ label: z.string().optional().describe('Find input by its <label> text, aria-label, or placeholder'),
132
+ name: z.string().optional().describe('Find input by its name attribute'),
133
+ placeholder: z.string().optional().describe('Find input by its placeholder text'),
134
+ value: z.string().optional().describe('Value to set (for single-field mode)'),
135
+ componentId: z.number().optional().describe('Scope to this component'),
136
+ inputs: z.record(z.string(), z.string()).optional().describe('Multi-fill: object mapping field names/IDs to values. Example: {"email": "test@test.com", "password": "secret"}'),
137
+ },
138
+ async ({ label, name, placeholder, value, componentId, inputs }) => {
139
+ if (!bridge.isConnected()) return noConnection('what_fill');
140
+
141
+ // Validate: need either single-field or multi-field args
142
+ if (!inputs && !label && !name && !placeholder) {
143
+ return errorResponse(
144
+ 'No field selector provided. Specify label, name, placeholder, or inputs (multi-fill).',
145
+ ['Use what_page_map to see available form fields with their labels and names.']
146
+ );
147
+ }
148
+
149
+ if (!inputs && value === undefined) {
150
+ return errorResponse(
151
+ 'No value provided. Specify value for single-field fill, or use inputs for multi-fill.',
152
+ ['Example: what_fill({label: "Email", value: "test@test.com"})']
153
+ );
154
+ }
155
+
156
+ try {
157
+ const result = await bridge.sendCommand('fill', {
158
+ label, name, placeholder, value, componentId, inputs,
159
+ }, 10000);
160
+
161
+ if (result.error) {
162
+ return errorResponse(result.error, [
163
+ result.suggestion || 'Use what_page_map to see available form fields.',
164
+ ]);
165
+ }
166
+
167
+ // Build summary
168
+ let summary;
169
+ if (result.mode === 'multi') {
170
+ summary = `Filled ${result.filledCount} of ${result.results?.length || 0} fields.`;
171
+ if (result.failedCount > 0) {
172
+ const failed = result.results.filter(r => !r.filled).map(r => r.field);
173
+ summary += ` Failed: ${failed.join(', ')}.`;
174
+ }
175
+ } else {
176
+ summary = `Filled ${result.element?.tag || 'input'} (${result.matched}): "${result.previousValue || ''}" -> "${result.currentValue || ''}".`;
177
+ if (result.validation && !result.validation.valid) {
178
+ const issues = Object.entries(result.validation)
179
+ .filter(([k, v]) => k !== 'valid' && v === true)
180
+ .map(([k]) => k);
181
+ summary += ` Validation issues: ${issues.join(', ')}.`;
182
+ }
183
+ }
184
+
185
+ return ok({ summary, ...result });
186
+ } catch (e) {
187
+ return errorResponse(`Fill failed: ${e.message}`, ['Check what_connection_status.']);
188
+ }
189
+ }
190
+ );
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Tool 3 — what_interact
194
+ // ---------------------------------------------------------------------------
195
+
196
+ server.tool(
197
+ 'what_interact',
198
+ 'Perform high-level page interactions: submit forms, select dropdown options, toggle checkboxes/switches, scroll to elements, hover, type text, clear fields, or focus elements.',
199
+ {
200
+ action: z.enum([
201
+ 'submit_form', 'select_option', 'toggle', 'scroll_to', 'hover', 'type', 'clear', 'focus',
202
+ ]).describe('The interaction to perform'),
203
+ componentId: z.number().optional().describe('Scope to this component'),
204
+ label: z.string().optional().describe('Target element by label text'),
205
+ text: z.string().optional().describe('Target element by visible text, or text to type'),
206
+ value: z.string().optional().describe('Value for select_option or text for type action'),
207
+ name: z.string().optional().describe('Target element by name attribute (for clear action)'),
208
+ },
209
+ async ({ action, componentId, label, text, value, name }) => {
210
+ if (!bridge.isConnected()) return noConnection('what_interact');
211
+
212
+ try {
213
+ const result = await bridge.sendCommand('interact', {
214
+ action, componentId, label, text, value, name,
215
+ }, 10000);
216
+
217
+ if (result.error) {
218
+ return errorResponse(result.error, [
219
+ 'Use what_page_map to see available interactive elements.',
220
+ result.availableOptions ? `Available options: ${JSON.stringify(result.availableOptions)}` : null,
221
+ ].filter(Boolean));
222
+ }
223
+
224
+ // Build summary per action type
225
+ let summary = `Action "${action}" completed.`;
226
+ switch (action) {
227
+ case 'submit_form':
228
+ summary = `Form submitted${result.formAction ? ` (action: ${result.formAction})` : ''}.`;
229
+ break;
230
+ case 'select_option':
231
+ summary = `Selected "${result.selectedText || result.currentValue}" (was "${result.previousValue}").`;
232
+ break;
233
+ case 'toggle':
234
+ summary = `Toggled from ${result.previousState} to ${result.currentState}.`;
235
+ break;
236
+ case 'scroll_to':
237
+ summary = `Scrolled to element. Position: ${result.viewportPosition}.`;
238
+ break;
239
+ case 'hover':
240
+ summary = `Hovering over ${result.element?.tag || 'element'}${result.element?.text ? ` ("${result.element.text}")` : ''}.`;
241
+ break;
242
+ case 'type':
243
+ summary = `Typed "${result.text}". Current value: "${result.currentValue}".`;
244
+ break;
245
+ case 'clear':
246
+ summary = `Cleared field (was "${result.previousValue}").`;
247
+ break;
248
+ case 'focus':
249
+ summary = `Focused ${result.element?.tag || 'element'}.`;
250
+ break;
251
+ }
252
+
253
+ return ok({ summary, ...result });
254
+ } catch (e) {
255
+ return errorResponse(`Interact failed: ${e.message}`, ['Check what_connection_status.']);
256
+ }
257
+ }
258
+ );
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Tool 4 — what_assert
262
+ // ---------------------------------------------------------------------------
263
+
264
+ server.tool(
265
+ 'what_assert',
266
+ 'Verify page state without screenshots. Check visible text, signal values, component existence, element counts, or current route. Returns pass/fail for each assertion.',
267
+ {
268
+ text: z.string().optional().describe('Assert this text exists on the page'),
269
+ visible: z.boolean().optional().describe('If true, text must also be visible (not hidden via CSS)'),
270
+ componentId: z.number().optional().describe('Assert this component is mounted (or combine with signalName/value to check its signals)'),
271
+ signalName: z.string().optional().describe('Assert a signal by name exists or has a specific value'),
272
+ signalId: z.number().optional().describe('Assert a signal by ID exists or has a specific value'),
273
+ value: z.any().optional().describe('Expected value for the signal assertion'),
274
+ selector: z.string().optional().describe('CSS selector to count matching elements'),
275
+ count: z.number().optional().describe('Expected number of elements matching selector'),
276
+ route: z.string().optional().describe('Assert the current route path'),
277
+ exists: z.boolean().optional().default(true).describe('For component assertions: true = should exist, false = should not exist'),
278
+ },
279
+ async ({ text, visible, componentId, signalName, signalId, value, selector, count, route, exists }) => {
280
+ if (!bridge.isConnected()) return noConnection('what_assert');
281
+
282
+ // Must provide at least one assertion
283
+ if (text == null && componentId == null && signalName == null && signalId == null && selector == null && route == null) {
284
+ return errorResponse(
285
+ 'No assertion specified. Provide at least one of: text, componentId, signalName, signalId, selector, route.',
286
+ ['Example: what_assert({text: "Welcome", visible: true})']
287
+ );
288
+ }
289
+
290
+ try {
291
+ const result = await bridge.sendCommand('assert', {
292
+ text, visible, componentId, signalName, signalId, value, selector, count, route, exists,
293
+ }, 10000);
294
+
295
+ if (result.error) {
296
+ return errorResponse(result.error);
297
+ }
298
+
299
+ return ok(result);
300
+ } catch (e) {
301
+ return errorResponse(`Assert failed: ${e.message}`, ['Check what_connection_status.']);
302
+ }
303
+ }
304
+ );
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Tool 5 — what_wait
308
+ // ---------------------------------------------------------------------------
309
+
310
+ server.tool(
311
+ 'what_wait',
312
+ 'Wait for a condition to be met: text to appear/disappear, component to mount/unmount, signal to reach a value, or app to become idle. Returns what happened during the wait.',
313
+ {
314
+ text: z.string().optional().describe('Wait for this text to appear (or disappear if gone=true)'),
315
+ gone: z.boolean().optional().describe('If true, wait for text to disappear instead of appear'),
316
+ componentId: z.number().optional().describe('Wait for this component to mount (or unmount if mounted=false)'),
317
+ mounted: z.boolean().optional().default(true).describe('Wait for component to be mounted (true) or unmounted (false)'),
318
+ signalId: z.number().optional().describe('Wait for this signal to reach a specific value'),
319
+ signalName: z.string().optional().describe('Wait for this named signal to reach a specific value'),
320
+ value: z.any().optional().describe('The value to wait for (used with signalId or signalName)'),
321
+ idle: z.boolean().optional().describe('Wait until no reactive activity for 200ms'),
322
+ timeout: z.number().optional().default(5000).describe('Max wait time in ms (default: 5000, max: 30000)'),
323
+ },
324
+ async ({ text, gone, componentId, mounted, signalId, signalName, value, idle, timeout }) => {
325
+ if (!bridge.isConnected()) return noConnection('what_wait');
326
+
327
+ // Must provide at least one condition
328
+ if (text == null && componentId == null && signalId == null && signalName == null && !idle) {
329
+ return errorResponse(
330
+ 'No wait condition specified. Provide one of: text, componentId, signalId, signalName, idle.',
331
+ ['Example: what_wait({text: "Loading", gone: true})']
332
+ );
333
+ }
334
+
335
+ const clampedTimeout = Math.min(Math.max(timeout || 5000, 100), 30000);
336
+
337
+ try {
338
+ const result = await bridge.sendCommand('wait', {
339
+ text, gone, componentId, mounted, signalId, signalName, value, idle,
340
+ timeout: clampedTimeout,
341
+ }, clampedTimeout + 2000); // Give the bridge extra time beyond the wait timeout
342
+
343
+ if (result.error) {
344
+ return errorResponse(result.error);
345
+ }
346
+
347
+ return ok(result);
348
+ } catch (e) {
349
+ // Timeout on bridge side is expected — the wait itself handles timeouts
350
+ if (e.message?.includes('timed out')) {
351
+ return ok({
352
+ conditionMet: false,
353
+ timedOut: true,
354
+ summary: `Wait timed out after ${clampedTimeout}ms.`,
355
+ });
356
+ }
357
+ return errorResponse(`Wait failed: ${e.message}`, ['Check what_connection_status.']);
358
+ }
359
+ }
360
+ );
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Tool 6 — what_page_map (enhanced version with interaction hints)
364
+ // ---------------------------------------------------------------------------
365
+
366
+ server.tool(
367
+ 'what_page_map_interactive',
368
+ 'Get a complete map of everything you can interact with on the page. For each element, shows the exact tool and arguments to use. Includes forms with all fields, buttons with suggested click args, links with targets, selects with options. The "what can I do on this page?" tool.',
369
+ {
370
+ maxElements: z.number().optional().default(300).describe('Max elements to include (default: 300)'),
371
+ },
372
+ async ({ maxElements }) => {
373
+ if (!bridge.isConnected()) return noConnection('what_page_map_interactive');
374
+
375
+ try {
376
+ const result = await bridge.sendCommand('enhanced-page-map', {
377
+ maxElements: maxElements || 300,
378
+ }, 10000);
379
+
380
+ if (result.error) {
381
+ return errorResponse(result.error);
382
+ }
383
+
384
+ const { viewport, currentPath, interactives, forms, landmarks, headings, components, totalElements } = result;
385
+
386
+ // Build a rich summary
387
+ const buttonCount = interactives.filter(i => i.tag === 'button' || i.role === 'button').length;
388
+ const inputCount = interactives.filter(i => i.tag === 'input' || i.tag === 'textarea').length;
389
+ const linkCount = interactives.filter(i => i.tag === 'a').length;
390
+ const selectCount = interactives.filter(i => i.tag === 'select').length;
391
+
392
+ const summary = `Page "${currentPath}" (${viewport.width}x${viewport.height}). ` +
393
+ `${buttonCount} buttons, ${inputCount} inputs, ${linkCount} links, ${selectCount} selects. ` +
394
+ `${forms.length} forms. ${components.length} WhatFW components. ` +
395
+ `${totalElements} total elements mapped.`;
396
+
397
+ return ok({
398
+ summary,
399
+ viewport,
400
+ currentPath,
401
+ interactives,
402
+ forms,
403
+ landmarks,
404
+ headings,
405
+ components,
406
+ totalElements,
407
+ interactionGuide: {
408
+ clickButton: 'what_click({text: "Button Text"})',
409
+ fillInput: 'what_fill({label: "Field Label", value: "text"})',
410
+ selectOption: 'what_interact({action: "select_option", label: "Dropdown", value: "option"})',
411
+ submitForm: 'what_interact({action: "submit_form", componentId: N})',
412
+ toggleCheckbox: 'what_interact({action: "toggle", text: "Checkbox Label"})',
413
+ },
414
+ });
415
+ } catch (e) {
416
+ return errorResponse(`Page map failed: ${e.message}`, ['Check what_connection_status.']);
417
+ }
418
+ }
419
+ );
420
+ }
package/src/tools.js CHANGED
@@ -52,7 +52,7 @@ export function registerTools(server, bridge) {
52
52
 
53
53
  server.tool(
54
54
  'what_connection_status',
55
- 'Check if a What Framework app is connected via WebSocket',
55
+ 'Bootstrap endpoint: check connection, get app info, see available tools and recommended workflow',
56
56
  {},
57
57
  async () => {
58
58
  const connected = bridge.isConnected();
@@ -61,31 +61,84 @@ export function registerTools(server, bridge) {
61
61
  const effectCount = snapshot?.effects?.length || 0;
62
62
  const componentCount = snapshot?.components?.length || 0;
63
63
 
64
+ // Try to get app metadata from the browser
65
+ let appInfo = null;
66
+ if (connected) {
67
+ try {
68
+ appInfo = await bridge.sendCommand('get-app-info');
69
+ // If the client doesn't support this command, appInfo may be null or have an error
70
+ if (appInfo?.error) appInfo = null;
71
+ } catch {
72
+ // Old client without get-app-info support — skip gracefully
73
+ appInfo = null;
74
+ }
75
+ }
76
+
64
77
  let summary;
65
78
  if (!connected) {
66
79
  summary = 'No browser connected. Start your app with the what-devtools-mcp Vite plugin and refresh the page.';
67
80
  } else if (!snapshot) {
68
81
  summary = 'Browser connected but no snapshot received yet. Try refreshing the page.';
69
82
  } else {
70
- summary = `Connected. App has ${signalCount} signals, ${effectCount} effects, ${componentCount} components.`;
83
+ summary = `Connected to ${appInfo?.title || 'app'} at ${appInfo?.url || 'unknown URL'}. ${signalCount} signals, ${effectCount} effects, ${componentCount} components.`;
71
84
  }
72
85
 
73
86
  const result = {
74
87
  summary,
75
88
  connected,
76
89
  hasSnapshot: snapshot !== null,
90
+ // App info (from browser)
91
+ app: appInfo ? {
92
+ url: appInfo.url,
93
+ title: appInfo.title,
94
+ viewport: appInfo.viewport,
95
+ version: appInfo.version,
96
+ entryPoint: appInfo.entryPoint,
97
+ } : null,
98
+ // Counts
77
99
  signalCount,
78
100
  effectCount,
79
101
  componentCount,
80
- };
81
-
82
- if (!connected) {
83
- result.nextSteps = [
102
+ // Framework primer for agents that don't know WhatFW
103
+ framework: 'What Framework: signal-based reactivity. Components run ONCE (not like React). signal(val) for state — read with sig(), write with sig(newVal). effect() for side effects. computed() for derived values. Import from "what-framework".',
104
+ // Recommended next steps
105
+ workflow: connected ? [
106
+ 'what_components — see component tree and IDs',
107
+ 'what_signals {filter: "yourSignalName"} — check specific state (always filter!)',
108
+ 'what_diagnose — one-call health check',
109
+ 'what_look {componentId: N} — visual info without screenshot',
110
+ 'what_errors — check for runtime errors',
111
+ ] : [
84
112
  '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
- }
113
+ 'Or manually call connectDevToolsMCP() in your browser console',
114
+ ],
115
+ // Tool catalog so agents know what's available
116
+ tools: [
117
+ { name: 'what_components', desc: 'List mounted components with IDs' },
118
+ { name: 'what_signals', desc: 'List signals with values (use filter!)' },
119
+ { name: 'what_effects', desc: 'List effects with deps and run counts' },
120
+ { name: 'what_explain', desc: 'Everything about one component (signals + effects + DOM + errors)' },
121
+ { name: 'what_look', desc: 'Visual info without image: styles, layout, dimensions' },
122
+ { name: 'what_screenshot', desc: 'Cropped component screenshot (5-20KB)' },
123
+ { name: 'what_page_map', desc: 'Full page layout skeleton' },
124
+ { name: 'what_diagnose', desc: 'One-call health check (errors + perf + reactivity)' },
125
+ { name: 'what_errors', desc: 'Runtime errors with fix suggestions' },
126
+ { name: 'what_signal_trace', desc: 'Why did a signal change? Causal chain.' },
127
+ { name: 'what_dependency_graph', desc: 'Reactive dependency graph' },
128
+ { name: 'what_watch', desc: 'Observe events over a time window' },
129
+ { name: 'what_set_signal', desc: 'Change a signal value in the live app' },
130
+ { name: 'what_navigate', desc: 'Navigate to a route' },
131
+ { name: 'what_click', desc: 'Click elements by text, aria-label, test-id, or role. Reports what changed.' },
132
+ { name: 'what_fill', desc: 'Fill form inputs by label, name, or placeholder. Returns validation state.' },
133
+ { name: 'what_interact', desc: 'Submit forms, select options, toggle checkboxes, hover, scroll, type.' },
134
+ { name: 'what_assert', desc: 'Verify text, signals, components, element counts, routes — no screenshots.' },
135
+ { name: 'what_wait', desc: 'Wait for text, component mount, signal value, or idle state.' },
136
+ { name: 'what_page_map_interactive', desc: 'Full interactive element map with exact tool/args to use for each.' },
137
+ { name: 'what_lint', desc: 'Static analysis for code (no browser needed)' },
138
+ { name: 'what_scaffold', desc: 'Generate boilerplate (no browser needed)' },
139
+ { name: 'what_fix', desc: 'Error diagnosis with code examples (no browser needed)' },
140
+ ],
141
+ };
89
142
 
90
143
  return {
91
144
  content: [{
@@ -98,12 +151,14 @@ export function registerTools(server, bridge) {
98
151
 
99
152
  server.tool(
100
153
  'what_signals',
101
- 'List all reactive signals with current values. Filter by name regex or ID.',
154
+ 'List all reactive signals with current values. Filter by name regex or ID. Named signals are sorted first for relevance.',
102
155
  {
103
156
  filter: z.string().optional().describe('Regex to filter signal names (ignored if id is set)'),
104
157
  id: z.number().optional().describe('Get a specific signal by ID (takes precedence over filter)'),
158
+ limit: z.number().optional().default(20).describe('Max signals to return (default: 20, max: 100)'),
159
+ named_only: z.boolean().optional().default(false).describe('If true, only return signals with debug names (filters out anonymous internal signals)'),
105
160
  },
106
- async ({ filter, id }) => {
161
+ async ({ filter, id, limit, named_only }) => {
107
162
  if (!bridge.isConnected()) {
108
163
  return noConnection('what_signals');
109
164
  }
@@ -124,6 +179,40 @@ export function registerTools(server, bridge) {
124
179
  }
125
180
  }
126
181
 
182
+ // Sort: named signals first (more useful), then by ID
183
+ signals.sort((a, b) => {
184
+ const aHasName = a.name && !a.name.startsWith('signal_');
185
+ const bHasName = b.name && !b.name.startsWith('signal_');
186
+ if (aHasName && !bHasName) return -1;
187
+ if (!aHasName && bHasName) return 1;
188
+ return a.id - b.id;
189
+ });
190
+
191
+ // Filter to named-only if requested
192
+ if (named_only) {
193
+ signals = signals.filter(s => s.name && !s.name.startsWith('signal_') && !s.name.startsWith('effect_'));
194
+ }
195
+
196
+ // Clean up circular references in values
197
+ signals = signals.map(s => {
198
+ const val = s.value;
199
+ if (val === '[Circular]' || (typeof val === 'string' && val.includes('[Circular]'))) {
200
+ return { ...s, value: '[ref]', _circular: true };
201
+ }
202
+ // Truncate large array/object values
203
+ if (typeof val === 'object' && val !== null) {
204
+ const str = JSON.stringify(val);
205
+ if (str && str.length > 200) {
206
+ return { ...s, value: str.substring(0, 197) + '...', _truncated: true };
207
+ }
208
+ }
209
+ return s;
210
+ });
211
+
212
+ // Apply limit AFTER sorting and filtering
213
+ const totalBeforeLimit = signals.length;
214
+ signals = signals.slice(0, Math.min(limit || 20, 100));
215
+
127
216
  // Build summary
128
217
  const valuePreviews = signals.slice(0, 5).map(s => {
129
218
  const val = typeof s.value === 'string' ? `'${s.value}'` : JSON.stringify(s.value);
@@ -133,7 +222,8 @@ export function registerTools(server, bridge) {
133
222
  const filterNote = id != null ? ` 1 matched id=${id}.` : filter ? ` ${signals.length} match filter '${filter}'.` : '';
134
223
  const valuesNote = valuePreviews.length > 0 ? ` Values: ${valuePreviews.join(', ')}` : '';
135
224
  const moreNote = signals.length > 5 ? `, ... (${signals.length - 5} more)` : '';
136
- const summary = `${totalCount} signals total.${filterNote}${valuesNote}${moreNote}`;
225
+ const limitNote = totalBeforeLimit > signals.length ? ` Showing ${signals.length} of ${totalBeforeLimit} (use limit param for more).` : '';
226
+ const summary = `${totalCount} signals total.${filterNote}${valuesNote}${moreNote}${limitNote}`;
137
227
 
138
228
  return {
139
229
  content: [{
@@ -225,7 +315,12 @@ export function registerTools(server, bridge) {
225
315
 
226
316
  // Build tree summary
227
317
  const { tree, depth } = buildComponentTreeSummary(components);
228
- const summary = `${totalCount} components mounted. Tree depth: ${depth}. Root: ${tree}`;
318
+
319
+ // Add source file hint based on component names
320
+ const sourceHint = 'Component source files are typically in the same directory as the app entry point. Use file search to find: ' +
321
+ components.slice(0, 5).map(c => c.name).filter(Boolean).join(', ');
322
+
323
+ const summary = `${totalCount} components mounted. Tree depth: ${depth}. Root: ${tree}. ${sourceHint}`;
229
324
 
230
325
  return {
231
326
  content: [{
@@ -233,6 +328,7 @@ export function registerTools(server, bridge) {
233
328
  text: JSON.stringify({
234
329
  summary,
235
330
  count: components.length,
331
+ sourceHint,
236
332
  components,
237
333
  }, null, 2),
238
334
  }],
@@ -1,21 +1,59 @@
1
1
  /**
2
2
  * Vite plugin to auto-inject what-devtools-mcp client into dev server.
3
3
  * Only active during `vite dev` (apply: 'serve').
4
+ *
5
+ * Token resolution order:
6
+ * 1. Explicit `token` option passed to the plugin
7
+ * 2. WHAT_MCP_TOKEN environment variable
8
+ * 3. File-based cache (node_modules/.cache/what-devtools-mcp/token) — written by the bridge
9
+ * 4. Empty string — client will auto-discover via HTTP endpoint at runtime
4
10
  */
5
11
 
6
- export default function whatDevToolsMCP({ port = 9229 } = {}) {
12
+ import { readFileSync } from 'fs';
13
+ import { join } from 'path';
14
+
15
+ function resolveToken(explicitToken) {
16
+ // 1. Explicit token
17
+ if (explicitToken) return explicitToken;
18
+
19
+ // 2. Environment variable
20
+ if (process.env.WHAT_MCP_TOKEN) return process.env.WHAT_MCP_TOKEN;
21
+
22
+ // 3. File-based cache (written by the bridge on startup)
23
+ try {
24
+ const cacheFile = join(process.cwd(), 'node_modules', '.cache', 'what-devtools-mcp', 'token');
25
+ const data = JSON.parse(readFileSync(cacheFile, 'utf-8'));
26
+ if (data.token) return data.token;
27
+ } catch {
28
+ // Cache file doesn't exist — bridge hasn't started yet, or different cwd
29
+ }
30
+
31
+ // 4. Empty — client will auto-discover via HTTP at runtime
32
+ return '';
33
+ }
34
+
35
+ export default function whatDevToolsMCP({ port = 9229, token = '' } = {}) {
7
36
  return {
8
37
  name: 'what-devtools-mcp',
9
38
  apply: 'serve',
10
39
  transformIndexHtml(html) {
40
+ const tokenValue = resolveToken(token);
11
41
  return html.replace(
12
42
  '</body>',
13
43
  `<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} });
44
+ Promise.all([
45
+ import('what-core'),
46
+ import('what-devtools'),
47
+ import('what-devtools-mcp/client'),
48
+ ]).then(([core, devtools, mcp]) => {
49
+ devtools.installDevTools(core);
50
+ mcp.connectDevToolsMCP({ port: ${port}, token: ${JSON.stringify(tokenValue)} });
51
+ }).catch((error) => {
52
+ console.warn(
53
+ '[what-devtools-mcp] DevTools injection failed. Install what-core, what-devtools, and what-devtools-mcp, then verify Vite aliases/package exports.',
54
+ error
55
+ );
56
+ });
19
57
  </script>
20
58
  </body>`
21
59
  );