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,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-first MCP tools for what-devtools-mcp.
|
|
3
|
+
* 5 new tools: lint, scaffold, validate, perf, fix.
|
|
4
|
+
* Plus enhancements to existing tools (errors, snapshot).
|
|
5
|
+
*
|
|
6
|
+
* These tools make WhatFW THE framework designed for AI coding agents.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
// --- Error code database (mirrors packages/core/src/errors.js) ---
|
|
12
|
+
const ERROR_DATABASE = {
|
|
13
|
+
ERR_INFINITE_EFFECT: {
|
|
14
|
+
code: 'ERR_INFINITE_EFFECT',
|
|
15
|
+
severity: 'error',
|
|
16
|
+
diagnosis: 'An effect reads and writes the same signal, creating an infinite update cycle. The effect triggers itself on every run.',
|
|
17
|
+
suggestedFix: 'Use untrack() to read the signal without subscribing, or restructure so the read and write are in separate effects.',
|
|
18
|
+
codeExample: `// Bad — reads and writes count, creating a cycle:
|
|
19
|
+
effect(() => { count(count() + 1); });
|
|
20
|
+
|
|
21
|
+
// Fix — use untrack() so the read doesn't subscribe:
|
|
22
|
+
effect(() => { count(untrack(count) + 1); });
|
|
23
|
+
|
|
24
|
+
// Better — split into separate logic:
|
|
25
|
+
const doubled = computed(() => count() * 2);`,
|
|
26
|
+
},
|
|
27
|
+
ERR_MISSING_SIGNAL_READ: {
|
|
28
|
+
code: 'ERR_MISSING_SIGNAL_READ',
|
|
29
|
+
severity: 'warning',
|
|
30
|
+
diagnosis: 'A signal function reference is used where its value was intended. Signals are functions — they must be called with () to read their value.',
|
|
31
|
+
suggestedFix: 'Add () after the signal name. In JSX: {count()} not {count}. In template literals: `${count()}` not `${count}`.',
|
|
32
|
+
codeExample: `// Bad:
|
|
33
|
+
<span>{count}</span> // renders "[Function]"
|
|
34
|
+
|
|
35
|
+
// Good:
|
|
36
|
+
<span>{count()}</span> // renders the actual value`,
|
|
37
|
+
},
|
|
38
|
+
ERR_HYDRATION_MISMATCH: {
|
|
39
|
+
code: 'ERR_HYDRATION_MISMATCH',
|
|
40
|
+
severity: 'error',
|
|
41
|
+
diagnosis: 'Server-rendered HTML does not match what the client expects. This causes the DOM to be rebuilt, losing any server-rendered content benefits.',
|
|
42
|
+
suggestedFix: 'Avoid reading browser-only APIs (window, localStorage, navigator) during the initial render. Use onMount() for client-only logic.',
|
|
43
|
+
codeExample: `// Bad — different on server vs client:
|
|
44
|
+
function App() {
|
|
45
|
+
return <p>{window.innerWidth}</p>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Good — use onMount for client-only values:
|
|
49
|
+
function App() {
|
|
50
|
+
const width = signal(0);
|
|
51
|
+
onMount(() => width(window.innerWidth));
|
|
52
|
+
return <p>{width()}</p>;
|
|
53
|
+
}`,
|
|
54
|
+
},
|
|
55
|
+
ERR_ORPHAN_EFFECT: {
|
|
56
|
+
code: 'ERR_ORPHAN_EFFECT',
|
|
57
|
+
severity: 'warning',
|
|
58
|
+
diagnosis: 'An effect was created outside any reactive root or component. It will never be automatically cleaned up, causing a memory leak.',
|
|
59
|
+
suggestedFix: 'Create effects inside component functions (where they are auto-tracked) or wrap in createRoot().',
|
|
60
|
+
codeExample: `// Bad — orphaned, leaks memory:
|
|
61
|
+
effect(() => console.log(count()));
|
|
62
|
+
|
|
63
|
+
// Good — inside a root with cleanup:
|
|
64
|
+
createRoot(dispose => {
|
|
65
|
+
effect(() => console.log(count()));
|
|
66
|
+
// later: dispose() cleans up
|
|
67
|
+
});`,
|
|
68
|
+
},
|
|
69
|
+
ERR_SIGNAL_WRITE_IN_RENDER: {
|
|
70
|
+
code: 'ERR_SIGNAL_WRITE_IN_RENDER',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
diagnosis: 'A signal is being written during the component render phase (the component function body). This triggers immediate re-execution and can cause infinite loops.',
|
|
73
|
+
suggestedFix: 'Move signal writes into event handlers, effects, or onMount(). The component body should only read signals.',
|
|
74
|
+
codeExample: `// Bad — write during render:
|
|
75
|
+
function Counter() {
|
|
76
|
+
count(count() + 1); // infinite loop!
|
|
77
|
+
return <span>{count()}</span>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Good — write in event handler:
|
|
81
|
+
function Counter() {
|
|
82
|
+
return <button onclick={() => count(c => c + 1)}>{count()}</button>;
|
|
83
|
+
}`,
|
|
84
|
+
},
|
|
85
|
+
ERR_MISSING_CLEANUP: {
|
|
86
|
+
code: 'ERR_MISSING_CLEANUP',
|
|
87
|
+
severity: 'warning',
|
|
88
|
+
diagnosis: 'An effect sets up a resource (event listener, timer, connection) but does not return a cleanup function. This causes memory leaks when the component unmounts.',
|
|
89
|
+
suggestedFix: 'Return a cleanup function from the effect that removes the resource.',
|
|
90
|
+
codeExample: `// Bad — no cleanup:
|
|
91
|
+
effect(() => {
|
|
92
|
+
window.addEventListener('resize', handler);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Good — return cleanup:
|
|
96
|
+
effect(() => {
|
|
97
|
+
window.addEventListener('resize', handler);
|
|
98
|
+
return () => window.removeEventListener('resize', handler);
|
|
99
|
+
});`,
|
|
100
|
+
},
|
|
101
|
+
ERR_UNSAFE_INNERHTML: {
|
|
102
|
+
code: 'ERR_UNSAFE_INNERHTML',
|
|
103
|
+
severity: 'warning',
|
|
104
|
+
diagnosis: 'innerHTML is set directly on an element without the __html safety marker. This is an XSS risk if the content comes from user input.',
|
|
105
|
+
suggestedFix: 'Use { __html: content } to explicitly mark innerHTML as intentional, or use the html tagged template literal.',
|
|
106
|
+
codeExample: `// Bad — raw innerHTML (XSS risk):
|
|
107
|
+
<div innerHTML={userInput} />
|
|
108
|
+
|
|
109
|
+
// Good — explicit opt-in:
|
|
110
|
+
<div innerHTML={{ __html: sanitizedContent }} />`,
|
|
111
|
+
},
|
|
112
|
+
ERR_MISSING_KEY: {
|
|
113
|
+
code: 'ERR_MISSING_KEY',
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
diagnosis: 'A list is rendered without key props. Without keys, the framework cannot efficiently track item identity during reordering, leading to incorrect UI updates.',
|
|
116
|
+
suggestedFix: 'Add a unique key prop to each list item using a stable identifier (like a database ID), not the array index.',
|
|
117
|
+
codeExample: `// Bad — no key:
|
|
118
|
+
<For each={items()}>{item => <li>{item.name}</li>}</For>
|
|
119
|
+
|
|
120
|
+
// Good — stable key:
|
|
121
|
+
<For each={items()}>{item => <li key={item.id}>{item.name}</li>}</For>`,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// --- Lint Patterns ---
|
|
126
|
+
// Static analysis rules applied to code snippets.
|
|
127
|
+
|
|
128
|
+
const LINT_RULES = [
|
|
129
|
+
{
|
|
130
|
+
id: 'missing-signal-read',
|
|
131
|
+
code: 'ERR_MISSING_SIGNAL_READ',
|
|
132
|
+
severity: 'error',
|
|
133
|
+
test(code) {
|
|
134
|
+
const issues = [];
|
|
135
|
+
// Find signal declarations
|
|
136
|
+
const signalDecls = [...code.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:signal|useSignal)\s*\(/g)];
|
|
137
|
+
const signalNames = signalDecls.map(m => m[1]);
|
|
138
|
+
|
|
139
|
+
for (const name of signalNames) {
|
|
140
|
+
// Look for JSX usage without () — e.g., {count} but not {count()}
|
|
141
|
+
const jsxPattern = new RegExp(`\\{\\s*${name}\\s*\\}`, 'g');
|
|
142
|
+
let match;
|
|
143
|
+
while ((match = jsxPattern.exec(code)) !== null) {
|
|
144
|
+
// Make sure it's not {count()} — check the char before }
|
|
145
|
+
const snippet = code.slice(match.index, match.index + match[0].length);
|
|
146
|
+
if (!snippet.includes(`${name}(`)) {
|
|
147
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
148
|
+
issues.push({
|
|
149
|
+
severity: 'error',
|
|
150
|
+
code: 'ERR_MISSING_SIGNAL_READ',
|
|
151
|
+
message: `Signal "${name}" used in JSX without calling () — will render as "[Function]". Use {${name}()} instead.`,
|
|
152
|
+
line,
|
|
153
|
+
suggestedFix: `Change {${name}} to {${name}()}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Template literal without ()
|
|
159
|
+
const templatePattern = new RegExp(`\\\$\\{\\s*${name}\\s*\\}`, 'g');
|
|
160
|
+
while ((match = templatePattern.exec(code)) !== null) {
|
|
161
|
+
if (!match[0].includes(`${name}(`)) {
|
|
162
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
163
|
+
issues.push({
|
|
164
|
+
severity: 'error',
|
|
165
|
+
code: 'ERR_MISSING_SIGNAL_READ',
|
|
166
|
+
message: `Signal "${name}" in template literal without () — will stringify as "[Function]". Use \${${name}()} instead.`,
|
|
167
|
+
line,
|
|
168
|
+
suggestedFix: `Change \${${name}} to \${${name}()}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return issues;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'innerhtml-without-html',
|
|
178
|
+
code: 'ERR_UNSAFE_INNERHTML',
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
test(code) {
|
|
181
|
+
const issues = [];
|
|
182
|
+
const pattern = /innerHTML\s*=\s*\{(?!.*__html)/g;
|
|
183
|
+
let match;
|
|
184
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
185
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
186
|
+
issues.push({
|
|
187
|
+
severity: 'warning',
|
|
188
|
+
code: 'ERR_UNSAFE_INNERHTML',
|
|
189
|
+
message: 'innerHTML set without __html safety marker — potential XSS risk.',
|
|
190
|
+
line,
|
|
191
|
+
suggestedFix: 'Use innerHTML={{ __html: content }} to explicitly mark as intentional.',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return issues;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'effect-writes-read-signal',
|
|
199
|
+
code: 'ERR_INFINITE_EFFECT',
|
|
200
|
+
severity: 'error',
|
|
201
|
+
test(code) {
|
|
202
|
+
const issues = [];
|
|
203
|
+
// Find effect blocks and check for read+write of same signal
|
|
204
|
+
const effectPattern = /effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
205
|
+
let match;
|
|
206
|
+
while ((match = effectPattern.exec(code)) !== null) {
|
|
207
|
+
const body = match[1];
|
|
208
|
+
// Find signal reads: name()
|
|
209
|
+
const reads = [...body.matchAll(/(\w+)\(\)/g)].map(m => m[1]);
|
|
210
|
+
// Find signal writes: name(value) where value is not empty
|
|
211
|
+
const writes = [...body.matchAll(/(\w+)\([^)]+\)/g)].map(m => m[1]);
|
|
212
|
+
|
|
213
|
+
for (const name of reads) {
|
|
214
|
+
if (writes.includes(name) && name !== 'console' && name !== 'Math' && name !== 'JSON' && name !== 'Date') {
|
|
215
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
216
|
+
issues.push({
|
|
217
|
+
severity: 'error',
|
|
218
|
+
code: 'ERR_INFINITE_EFFECT',
|
|
219
|
+
message: `Effect reads and writes signal "${name}" — will cause infinite loop. Use untrack(${name}) for the read.`,
|
|
220
|
+
line,
|
|
221
|
+
suggestedFix: `Replace ${name}() reads with untrack(${name}) inside this effect.`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return issues;
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'missing-cleanup',
|
|
231
|
+
code: 'ERR_MISSING_CLEANUP',
|
|
232
|
+
severity: 'warning',
|
|
233
|
+
test(code) {
|
|
234
|
+
const issues = [];
|
|
235
|
+
// Check effects that add listeners but don't return cleanup
|
|
236
|
+
const effectPattern = /effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
237
|
+
let match;
|
|
238
|
+
while ((match = effectPattern.exec(code)) !== null) {
|
|
239
|
+
const body = match[1];
|
|
240
|
+
const hasListener = body.includes('addEventListener') || body.includes('setInterval') || body.includes('setTimeout');
|
|
241
|
+
const hasReturn = body.includes('return');
|
|
242
|
+
|
|
243
|
+
if (hasListener && !hasReturn) {
|
|
244
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
245
|
+
const resource = body.includes('addEventListener') ? 'event listener'
|
|
246
|
+
: body.includes('setInterval') ? 'interval'
|
|
247
|
+
: 'timeout';
|
|
248
|
+
issues.push({
|
|
249
|
+
severity: 'warning',
|
|
250
|
+
code: 'ERR_MISSING_CLEANUP',
|
|
251
|
+
message: `Effect sets up ${resource} but does not return a cleanup function — memory leak risk.`,
|
|
252
|
+
line,
|
|
253
|
+
suggestedFix: `Return a cleanup function: return () => remove${resource === 'event listener' ? 'EventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return issues;
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// --- Scaffold Templates ---
|
|
263
|
+
|
|
264
|
+
const SCAFFOLD_TEMPLATES = {
|
|
265
|
+
component: ({ name, props = [], signals = [] }) => {
|
|
266
|
+
const propsParam = props.length > 0
|
|
267
|
+
? `{ ${props.join(', ')} }`
|
|
268
|
+
: '';
|
|
269
|
+
const signalDecls = signals.map(s => ` const ${s} = signal(${typeof s === 'string' && s.includes('is') ? 'false' : '""'}, '${s}');`).join('\n');
|
|
270
|
+
|
|
271
|
+
return `import { signal, effect } from 'what-framework';
|
|
272
|
+
|
|
273
|
+
function ${name}(${propsParam}) {
|
|
274
|
+
${signalDecls || ' // Add signals here'}
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div>
|
|
278
|
+
<h2>${name}</h2>
|
|
279
|
+
{/* Add your JSX here */}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default ${name};
|
|
285
|
+
`;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
page: ({ name, props = [], signals = [] }) => {
|
|
289
|
+
const signalDecls = signals.map(s => ` const ${s} = signal(null, '${s}');`).join('\n');
|
|
290
|
+
|
|
291
|
+
return `import { signal, effect, onMount } from 'what-framework';
|
|
292
|
+
import { Head } from 'what-framework';
|
|
293
|
+
|
|
294
|
+
function ${name}() {
|
|
295
|
+
${signalDecls || ' const loading = signal(true, \'loading\');'}
|
|
296
|
+
|
|
297
|
+
onMount(() => {
|
|
298
|
+
// Fetch data or initialize page
|
|
299
|
+
loading(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<>
|
|
304
|
+
<Head><title>${name}</title></Head>
|
|
305
|
+
<main>
|
|
306
|
+
<h1>${name}</h1>
|
|
307
|
+
{/* Page content */}
|
|
308
|
+
</main>
|
|
309
|
+
</>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export default ${name};
|
|
314
|
+
`;
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
form: ({ name, props = [], signals = [] }) => {
|
|
318
|
+
const fields = signals.length > 0 ? signals : ['email', 'password'];
|
|
319
|
+
const fieldDecls = fields.map(f => ` ${f}: { initial: '', rules: [rules.required('${f} is required')] },`).join('\n');
|
|
320
|
+
|
|
321
|
+
return `import { signal } from 'what-framework';
|
|
322
|
+
import { useForm, Input, ErrorMessage, rules } from 'what-framework';
|
|
323
|
+
|
|
324
|
+
function ${name}() {
|
|
325
|
+
const { fields, handleSubmit, isSubmitting, errors } = useForm({
|
|
326
|
+
fields: {
|
|
327
|
+
${fieldDecls}
|
|
328
|
+
},
|
|
329
|
+
onSubmit: async (values) => {
|
|
330
|
+
console.log('Form submitted:', values);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<form onsubmit={handleSubmit}>
|
|
336
|
+
${fields.map(f => ` <div>
|
|
337
|
+
<label for="${f}">${f.charAt(0).toUpperCase() + f.slice(1)}</label>
|
|
338
|
+
<Input field={fields.${f}} id="${f}" type="text" />
|
|
339
|
+
<ErrorMessage field={fields.${f}} />
|
|
340
|
+
</div>`).join('\n')}
|
|
341
|
+
<button type="submit" disabled={isSubmitting()}>
|
|
342
|
+
{isSubmitting() ? 'Submitting...' : 'Submit'}
|
|
343
|
+
</button>
|
|
344
|
+
</form>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export default ${name};
|
|
349
|
+
`;
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
store: ({ name, signals = [] }) => {
|
|
353
|
+
const stateFields = signals.length > 0
|
|
354
|
+
? signals.map(s => ` ${s}: null,`).join('\n')
|
|
355
|
+
: ' items: [],\n loading: false,\n error: null,';
|
|
356
|
+
|
|
357
|
+
return `import { createStore, derived } from 'what-framework';
|
|
358
|
+
|
|
359
|
+
const ${name.charAt(0).toLowerCase() + name.slice(1)} = createStore({
|
|
360
|
+
${stateFields}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Derived values
|
|
364
|
+
// const itemCount = derived(state => state.items.length);
|
|
365
|
+
|
|
366
|
+
// Actions
|
|
367
|
+
export function addItem(item) {
|
|
368
|
+
${name.charAt(0).toLowerCase() + name.slice(1)}.set(state => ({
|
|
369
|
+
...state,
|
|
370
|
+
items: [...state.items, item],
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function setLoading(value) {
|
|
375
|
+
${name.charAt(0).toLowerCase() + name.slice(1)}.set(state => ({ ...state, loading: value }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export default ${name.charAt(0).toLowerCase() + name.slice(1)};
|
|
379
|
+
`;
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
island: ({ name, props = [], signals = [] }) => {
|
|
383
|
+
const signalDecls = signals.map(s => ` const ${s} = signal(null, '${s}');`).join('\n');
|
|
384
|
+
|
|
385
|
+
return `import { signal, effect, onMount } from 'what-framework';
|
|
386
|
+
|
|
387
|
+
// Island component — hydrates independently on the client.
|
|
388
|
+
// Server renders the static shell; this code runs only in the browser.
|
|
389
|
+
function ${name}(${props.length > 0 ? `{ ${props.join(', ')} }` : ''}) {
|
|
390
|
+
${signalDecls || ' const active = signal(false, \'active\');'}
|
|
391
|
+
|
|
392
|
+
onMount(() => {
|
|
393
|
+
// Client-only initialization
|
|
394
|
+
console.log('${name} island hydrated');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div data-island="${name.toLowerCase()}">
|
|
399
|
+
{/* Interactive island content */}
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Mark as island for the compiler
|
|
405
|
+
${name}.island = true;
|
|
406
|
+
|
|
407
|
+
export default ${name};
|
|
408
|
+
`;
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// --- Register Agent Tools ---
|
|
413
|
+
|
|
414
|
+
export function registerAgentTools(server, bridge) {
|
|
415
|
+
|
|
416
|
+
// Helper responses
|
|
417
|
+
function errorResponse(message, nextSteps) {
|
|
418
|
+
return {
|
|
419
|
+
content: [{
|
|
420
|
+
type: 'text',
|
|
421
|
+
text: JSON.stringify({
|
|
422
|
+
error: message,
|
|
423
|
+
summary: message,
|
|
424
|
+
nextSteps: nextSteps || ['Check the arguments and try again.'],
|
|
425
|
+
}, null, 2),
|
|
426
|
+
}],
|
|
427
|
+
isError: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function ok(data) {
|
|
432
|
+
return {
|
|
433
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function noConnection(tool) {
|
|
438
|
+
return {
|
|
439
|
+
content: [{
|
|
440
|
+
type: 'text',
|
|
441
|
+
text: JSON.stringify({
|
|
442
|
+
error: 'No browser connected',
|
|
443
|
+
summary: `Cannot reach browser for ${tool}.`,
|
|
444
|
+
nextSteps: [
|
|
445
|
+
'Ensure your What Framework app is running with the devtools-mcp Vite plugin enabled.',
|
|
446
|
+
'Check that the bridge server is started (default port 9229).',
|
|
447
|
+
],
|
|
448
|
+
}, null, 2),
|
|
449
|
+
}],
|
|
450
|
+
isError: true,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// -----------------------------------------------------------------------
|
|
455
|
+
// Tool 1 — what_lint
|
|
456
|
+
// -----------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
server.tool(
|
|
459
|
+
'what_lint',
|
|
460
|
+
'Static analysis for What Framework code. Pass a code snippet, get back structured issues with fix suggestions. Works offline — no browser connection needed.',
|
|
461
|
+
{
|
|
462
|
+
code: z.string().describe('The What Framework code snippet to analyze'),
|
|
463
|
+
rules: z.array(z.string()).optional().describe('Specific rule IDs to run (default: all). Options: missing-signal-read, innerhtml-without-html, effect-writes-read-signal, missing-cleanup'),
|
|
464
|
+
},
|
|
465
|
+
async ({ code, rules: ruleFilter }) => {
|
|
466
|
+
let rulesToRun = LINT_RULES;
|
|
467
|
+
if (ruleFilter && ruleFilter.length > 0) {
|
|
468
|
+
rulesToRun = LINT_RULES.filter(r => ruleFilter.includes(r.id));
|
|
469
|
+
if (rulesToRun.length === 0) {
|
|
470
|
+
return errorResponse(
|
|
471
|
+
`No matching rules found. Available: ${LINT_RULES.map(r => r.id).join(', ')}`,
|
|
472
|
+
['Use one of the available rule IDs.']
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const issues = [];
|
|
478
|
+
for (const rule of rulesToRun) {
|
|
479
|
+
try {
|
|
480
|
+
const ruleIssues = rule.test(code);
|
|
481
|
+
issues.push(...ruleIssues);
|
|
482
|
+
} catch (e) {
|
|
483
|
+
// Rule crashed — skip silently
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Sort by line number
|
|
488
|
+
issues.sort((a, b) => (a.line || 0) - (b.line || 0));
|
|
489
|
+
|
|
490
|
+
const errorCount = issues.filter(i => i.severity === 'error').length;
|
|
491
|
+
const warningCount = issues.filter(i => i.severity === 'warning').length;
|
|
492
|
+
const summary = issues.length === 0
|
|
493
|
+
? 'No issues found. Code looks good.'
|
|
494
|
+
: `${issues.length} issue${issues.length !== 1 ? 's' : ''}: ${errorCount} error${errorCount !== 1 ? 's' : ''}, ${warningCount} warning${warningCount !== 1 ? 's' : ''}.`;
|
|
495
|
+
|
|
496
|
+
return ok({
|
|
497
|
+
summary,
|
|
498
|
+
issueCount: issues.length,
|
|
499
|
+
errorCount,
|
|
500
|
+
warningCount,
|
|
501
|
+
issues,
|
|
502
|
+
rulesChecked: rulesToRun.map(r => r.id),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// -----------------------------------------------------------------------
|
|
508
|
+
// Tool 2 — what_scaffold
|
|
509
|
+
// -----------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
server.tool(
|
|
512
|
+
'what_scaffold',
|
|
513
|
+
'Generate idiomatic What Framework boilerplate. Works offline — no browser connection needed.',
|
|
514
|
+
{
|
|
515
|
+
type: z.enum(['component', 'page', 'form', 'store', 'island']).describe('Type of code to generate'),
|
|
516
|
+
name: z.string().describe('Name for the generated component/store (PascalCase recommended)'),
|
|
517
|
+
props: z.array(z.string()).optional().describe('Prop names the component accepts'),
|
|
518
|
+
signals: z.array(z.string()).optional().describe('Signal names to declare in the component'),
|
|
519
|
+
},
|
|
520
|
+
async ({ type, name, props, signals }) => {
|
|
521
|
+
// Validate name is PascalCase
|
|
522
|
+
if (!/^[A-Z]/.test(name) && type !== 'store') {
|
|
523
|
+
return errorResponse(
|
|
524
|
+
`Component name "${name}" should be PascalCase (e.g., "${name.charAt(0).toUpperCase() + name.slice(1)}").`,
|
|
525
|
+
['Use PascalCase for component names — this is required by the What Framework compiler.']
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const template = SCAFFOLD_TEMPLATES[type];
|
|
530
|
+
if (!template) {
|
|
531
|
+
return errorResponse(
|
|
532
|
+
`Unknown scaffold type: ${type}`,
|
|
533
|
+
[`Available types: ${Object.keys(SCAFFOLD_TEMPLATES).join(', ')}`]
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const code = template({ name, props: props || [], signals: signals || [] });
|
|
538
|
+
|
|
539
|
+
return ok({
|
|
540
|
+
summary: `Generated ${type} "${name}" with ${(signals || []).length} signals and ${(props || []).length} props.`,
|
|
541
|
+
type,
|
|
542
|
+
name,
|
|
543
|
+
code,
|
|
544
|
+
instructions: [
|
|
545
|
+
`Save this as src/${type === 'page' ? 'pages/' : type === 'store' ? 'stores/' : 'components/'}${name}.jsx`,
|
|
546
|
+
type === 'island' ? 'The island will hydrate independently on the client.' : null,
|
|
547
|
+
type === 'store' ? 'Import the store in components that need shared state.' : null,
|
|
548
|
+
type === 'form' ? 'Install zod if using zodResolver for validation.' : null,
|
|
549
|
+
].filter(Boolean),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// -----------------------------------------------------------------------
|
|
555
|
+
// Tool 3 — what_validate
|
|
556
|
+
// -----------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
server.tool(
|
|
559
|
+
'what_validate',
|
|
560
|
+
'Validate What Framework component code by running it through the compiler pipeline in the browser. Returns parse errors and compiled output.',
|
|
561
|
+
{
|
|
562
|
+
code: z.string().describe('Component code to validate'),
|
|
563
|
+
format: z.enum(['esm', 'cjs']).optional().default('esm').describe('Output format (default: esm)'),
|
|
564
|
+
},
|
|
565
|
+
async ({ code, format }) => {
|
|
566
|
+
if (!bridge.isConnected()) return noConnection('what_validate');
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
const result = await bridge.sendCommand('validate-code', { code, format }, 10000);
|
|
570
|
+
|
|
571
|
+
if (result.error) {
|
|
572
|
+
return ok({
|
|
573
|
+
valid: false,
|
|
574
|
+
summary: `Validation failed: ${result.error}`,
|
|
575
|
+
errors: result.errors || [{ message: result.error, line: result.line }],
|
|
576
|
+
suggestions: result.suggestions || [
|
|
577
|
+
'Check for syntax errors in the JSX.',
|
|
578
|
+
'Ensure all imports are valid what-framework exports.',
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return ok({
|
|
584
|
+
valid: true,
|
|
585
|
+
summary: `Code is valid. Compiled to ${(result.output || '').length} chars of ${format}.`,
|
|
586
|
+
output: result.output,
|
|
587
|
+
warnings: result.warnings || [],
|
|
588
|
+
stats: {
|
|
589
|
+
signalCount: (result.output || '').match(/signal\s*\(/g)?.length || 0,
|
|
590
|
+
effectCount: (result.output || '').match(/effect\s*\(/g)?.length || 0,
|
|
591
|
+
componentCount: (result.output || '').match(/function\s+[A-Z]\w*\s*\(/g)?.length || 0,
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
} catch (e) {
|
|
595
|
+
// Fallback: do basic validation without browser
|
|
596
|
+
const issues = [];
|
|
597
|
+
|
|
598
|
+
// Check for basic syntax issues
|
|
599
|
+
const openParens = (code.match(/\(/g) || []).length;
|
|
600
|
+
const closeParens = (code.match(/\)/g) || []).length;
|
|
601
|
+
if (openParens !== closeParens) {
|
|
602
|
+
issues.push({ message: `Mismatched parentheses: ${openParens} open, ${closeParens} close`, severity: 'error' });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const openBraces = (code.match(/\{/g) || []).length;
|
|
606
|
+
const closeBraces = (code.match(/\}/g) || []).length;
|
|
607
|
+
if (openBraces !== closeBraces) {
|
|
608
|
+
issues.push({ message: `Mismatched braces: ${openBraces} open, ${closeBraces} close`, severity: 'error' });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Run lint rules as fallback
|
|
612
|
+
for (const rule of LINT_RULES) {
|
|
613
|
+
try {
|
|
614
|
+
issues.push(...rule.test(code));
|
|
615
|
+
} catch { /* skip */ }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return ok({
|
|
619
|
+
valid: issues.filter(i => i.severity === 'error').length === 0,
|
|
620
|
+
summary: issues.length > 0
|
|
621
|
+
? `Found ${issues.length} issues (browser validation unavailable, used static analysis).`
|
|
622
|
+
: 'No issues found via static analysis (browser validation unavailable).',
|
|
623
|
+
errors: issues,
|
|
624
|
+
note: 'Browser-based compiler validation was unavailable. Results are from static analysis only.',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// -----------------------------------------------------------------------
|
|
631
|
+
// Tool 4 — what_perf
|
|
632
|
+
// -----------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
server.tool(
|
|
635
|
+
'what_perf',
|
|
636
|
+
'Performance snapshot of the running What Framework app. Signal count, effect count, hot effects, largest subscriber counts, memory estimate.',
|
|
637
|
+
{
|
|
638
|
+
threshold: z.number().optional().default(10).describe('Flag effects that ran more than this many times per second (default: 10)'),
|
|
639
|
+
},
|
|
640
|
+
async ({ threshold }) => {
|
|
641
|
+
if (!bridge.isConnected()) return noConnection('what_perf');
|
|
642
|
+
|
|
643
|
+
let snapshot;
|
|
644
|
+
try {
|
|
645
|
+
snapshot = bridge.getOrRefreshSnapshot
|
|
646
|
+
? await bridge.getOrRefreshSnapshot()
|
|
647
|
+
: await bridge.refreshSnapshot();
|
|
648
|
+
} catch {
|
|
649
|
+
snapshot = bridge.getSnapshot();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (!snapshot) {
|
|
653
|
+
return errorResponse('No snapshot available.', [
|
|
654
|
+
'Refresh the browser page to trigger a snapshot.',
|
|
655
|
+
]);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const signals = snapshot.signals || [];
|
|
659
|
+
const effects = snapshot.effects || [];
|
|
660
|
+
const components = snapshot.components || [];
|
|
661
|
+
const recentEvents = bridge.getEvents(Date.now() - 1000); // last 1s
|
|
662
|
+
|
|
663
|
+
// Hot effects: ran more than threshold times
|
|
664
|
+
const hotEffects = effects
|
|
665
|
+
.filter(e => (e.runCount || 0) > threshold)
|
|
666
|
+
.sort((a, b) => (b.runCount || 0) - (a.runCount || 0))
|
|
667
|
+
.slice(0, 20)
|
|
668
|
+
.map(e => ({
|
|
669
|
+
id: e.id,
|
|
670
|
+
name: e.name || `effect_${e.id}`,
|
|
671
|
+
runCount: e.runCount,
|
|
672
|
+
depCount: (e.depSignalIds || e.deps || []).length,
|
|
673
|
+
componentId: e.componentId,
|
|
674
|
+
}));
|
|
675
|
+
|
|
676
|
+
// Largest subscriber counts (signals with most effects depending on them)
|
|
677
|
+
const subCounts = {};
|
|
678
|
+
for (const eff of effects) {
|
|
679
|
+
for (const sid of (eff.depSignalIds || eff.deps || [])) {
|
|
680
|
+
subCounts[sid] = (subCounts[sid] || 0) + 1;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const largestSubscribers = Object.entries(subCounts)
|
|
684
|
+
.map(([id, count]) => {
|
|
685
|
+
const sig = signals.find(s => s.id === Number(id));
|
|
686
|
+
return { id: Number(id), name: sig?.name || `signal_${id}`, subscriberCount: count };
|
|
687
|
+
})
|
|
688
|
+
.sort((a, b) => b.subscriberCount - a.subscriberCount)
|
|
689
|
+
.slice(0, 10);
|
|
690
|
+
|
|
691
|
+
// Event rate
|
|
692
|
+
const eventRate = recentEvents.length; // events per second
|
|
693
|
+
|
|
694
|
+
// Memory estimate (rough heuristic)
|
|
695
|
+
const signalMemory = signals.length * 200; // ~200 bytes per signal (value + subs set)
|
|
696
|
+
const effectMemory = effects.length * 300; // ~300 bytes per effect (fn + deps array)
|
|
697
|
+
const componentMemory = components.length * 500; // ~500 bytes per component
|
|
698
|
+
const totalEstimate = signalMemory + effectMemory + componentMemory;
|
|
699
|
+
const memoryStr = totalEstimate > 1048576
|
|
700
|
+
? `${(totalEstimate / 1048576).toFixed(1)} MB`
|
|
701
|
+
: `${(totalEstimate / 1024).toFixed(1)} KB`;
|
|
702
|
+
|
|
703
|
+
const issues = [];
|
|
704
|
+
if (hotEffects.length > 0) {
|
|
705
|
+
issues.push(`${hotEffects.length} effects exceeded ${threshold} runs (potential performance issue)`);
|
|
706
|
+
}
|
|
707
|
+
if (eventRate > 100) {
|
|
708
|
+
issues.push(`High event rate: ${eventRate} events/sec`);
|
|
709
|
+
}
|
|
710
|
+
if (signals.length > 1000) {
|
|
711
|
+
issues.push(`High signal count (${signals.length}) — consider consolidating with stores`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const summary = issues.length > 0
|
|
715
|
+
? `Performance concerns: ${issues.join('; ')}.`
|
|
716
|
+
: `Healthy. ${signals.length} signals, ${effects.length} effects, ${components.length} components. ${memoryStr} estimated.`;
|
|
717
|
+
|
|
718
|
+
return ok({
|
|
719
|
+
summary,
|
|
720
|
+
counts: {
|
|
721
|
+
signals: signals.length,
|
|
722
|
+
effects: effects.length,
|
|
723
|
+
components: components.length,
|
|
724
|
+
},
|
|
725
|
+
hotEffects,
|
|
726
|
+
largestSubscribers,
|
|
727
|
+
eventRate,
|
|
728
|
+
memoryEstimate: memoryStr,
|
|
729
|
+
memoryBytes: totalEstimate,
|
|
730
|
+
issues,
|
|
731
|
+
nextSteps: issues.length > 0 ? [
|
|
732
|
+
'Use what_dependency_graph to trace hot effect dependencies.',
|
|
733
|
+
'Consider using batch() to group signal writes.',
|
|
734
|
+
'Use computed() for derived values instead of effects.',
|
|
735
|
+
] : [],
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
// -----------------------------------------------------------------------
|
|
741
|
+
// Tool 5 — what_fix
|
|
742
|
+
// -----------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
server.tool(
|
|
745
|
+
'what_fix',
|
|
746
|
+
'Given a What Framework error code, get diagnosis, suggested fix, and code example. Works offline — no browser needed.',
|
|
747
|
+
{
|
|
748
|
+
error: z.string().describe('Error code (e.g., "ERR_INFINITE_EFFECT") or error message text'),
|
|
749
|
+
},
|
|
750
|
+
async ({ error: errorInput }) => {
|
|
751
|
+
// Try exact code match first
|
|
752
|
+
let entry = ERROR_DATABASE[errorInput];
|
|
753
|
+
|
|
754
|
+
// Try with ERR_ prefix
|
|
755
|
+
if (!entry) {
|
|
756
|
+
entry = ERROR_DATABASE[`ERR_${errorInput}`];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Try fuzzy match on message text
|
|
760
|
+
if (!entry) {
|
|
761
|
+
const lower = errorInput.toLowerCase();
|
|
762
|
+
for (const [code, def] of Object.entries(ERROR_DATABASE)) {
|
|
763
|
+
if (lower.includes(code.toLowerCase().replace('err_', '').replace(/_/g, ' '))) {
|
|
764
|
+
entry = def;
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Try keyword match
|
|
771
|
+
if (!entry) {
|
|
772
|
+
const lower = errorInput.toLowerCase();
|
|
773
|
+
const keywordMap = {
|
|
774
|
+
'infinite': 'ERR_INFINITE_EFFECT',
|
|
775
|
+
'loop': 'ERR_INFINITE_EFFECT',
|
|
776
|
+
'cycle': 'ERR_INFINITE_EFFECT',
|
|
777
|
+
'signal read': 'ERR_MISSING_SIGNAL_READ',
|
|
778
|
+
'missing ()': 'ERR_MISSING_SIGNAL_READ',
|
|
779
|
+
'function]': 'ERR_MISSING_SIGNAL_READ',
|
|
780
|
+
'hydration': 'ERR_HYDRATION_MISMATCH',
|
|
781
|
+
'mismatch': 'ERR_HYDRATION_MISMATCH',
|
|
782
|
+
'orphan': 'ERR_ORPHAN_EFFECT',
|
|
783
|
+
'cleanup': 'ERR_MISSING_CLEANUP',
|
|
784
|
+
'leak': 'ERR_MISSING_CLEANUP',
|
|
785
|
+
'render': 'ERR_SIGNAL_WRITE_IN_RENDER',
|
|
786
|
+
'innerhtml': 'ERR_UNSAFE_INNERHTML',
|
|
787
|
+
'xss': 'ERR_UNSAFE_INNERHTML',
|
|
788
|
+
'key': 'ERR_MISSING_KEY',
|
|
789
|
+
};
|
|
790
|
+
for (const [keyword, code] of Object.entries(keywordMap)) {
|
|
791
|
+
if (lower.includes(keyword)) {
|
|
792
|
+
entry = ERROR_DATABASE[code];
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (!entry) {
|
|
799
|
+
return ok({
|
|
800
|
+
found: false,
|
|
801
|
+
summary: `No matching error code found for "${errorInput}".`,
|
|
802
|
+
availableCodes: Object.keys(ERROR_DATABASE),
|
|
803
|
+
suggestion: 'Try one of the available error codes, or paste the exact error message.',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return ok({
|
|
808
|
+
found: true,
|
|
809
|
+
summary: `${entry.code}: ${entry.diagnosis.split('.')[0]}.`,
|
|
810
|
+
error: entry.code,
|
|
811
|
+
severity: entry.severity,
|
|
812
|
+
diagnosis: entry.diagnosis,
|
|
813
|
+
suggestedFix: entry.suggestedFix,
|
|
814
|
+
codeExample: entry.codeExample,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
);
|
|
818
|
+
}
|