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.
- package/package.json +3 -3
- package/src/bridge.js +66 -2
- package/src/client-commands.js +1636 -7
- package/src/client.js +53 -12
- package/src/index.js +478 -0
- package/src/tools-agent.js +183 -2
- package/src/tools-extended.js +426 -10
- package/src/tools-interact.js +420 -0
- package/src/tools.js +110 -14
- package/src/vite-plugin.js +44 -6
|
@@ -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
|
-
'
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
'
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}],
|
package/src/vite-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
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
|
);
|