what-devtools-mcp 0.8.4 → 0.11.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/package.json +13 -3
- package/src/bridge.js +57 -5
- package/src/client-commands.js +60 -28
- package/src/client.js +118 -65
- package/src/index.js +1 -1
- package/src/tools-agent.js +290 -5
- package/src/tools-extended.js +81 -19
- package/src/tools.js +164 -8
- package/src/vite-plugin.js +45 -12
package/src/tools.js
CHANGED
|
@@ -126,6 +126,7 @@ export function registerTools(server, bridge) {
|
|
|
126
126
|
{ name: 'what_signal_trace', desc: 'Why did a signal change? Causal chain.' },
|
|
127
127
|
{ name: 'what_dependency_graph', desc: 'Reactive dependency graph' },
|
|
128
128
|
{ name: 'what_watch', desc: 'Observe events over a time window' },
|
|
129
|
+
{ name: 'what_record_window', desc: 'Rank effects that re-ran during a recording window — what fired for this action?' },
|
|
129
130
|
{ name: 'what_set_signal', desc: 'Change a signal value in the live app' },
|
|
130
131
|
{ name: 'what_lint', desc: 'Static analysis for code (no browser needed)' },
|
|
131
132
|
{ name: 'what_scaffold', desc: 'Generate boilerplate (no browser needed)' },
|
|
@@ -285,7 +286,7 @@ export function registerTools(server, bridge) {
|
|
|
285
286
|
|
|
286
287
|
server.tool(
|
|
287
288
|
'what_components',
|
|
288
|
-
'List all mounted What Framework components',
|
|
289
|
+
'List all mounted What Framework components. Component IDs are ephemeral — they change on mount/unmount (view switches, conditional rendering, filters). Re-query this tool after any operation that may have remounted before using returned IDs.',
|
|
289
290
|
{
|
|
290
291
|
filter: z.string().optional().describe('Regex pattern to filter component names'),
|
|
291
292
|
},
|
|
@@ -476,19 +477,28 @@ export function registerTools(server, bridge) {
|
|
|
476
477
|
if (!bridge.isConnected()) return noConnection('what_errors');
|
|
477
478
|
let errors = bridge.getErrors(since);
|
|
478
479
|
|
|
480
|
+
// Build a name->component lookup so we can attribute errors to a
|
|
481
|
+
// mounted component when the stack references one. Re-fetched each call
|
|
482
|
+
// because component IDs are ephemeral (mount/unmount cycles).
|
|
483
|
+
const knownComponents = (bridge.getSnapshot()?.components || [])
|
|
484
|
+
.map(c => ({ id: c.id, name: c.name }))
|
|
485
|
+
.filter(c => c.name && /^[A-Z]/.test(c.name));
|
|
486
|
+
|
|
479
487
|
// Classify each error with structured codes and suggestions
|
|
480
488
|
const classified = errors.map((err, idx) => {
|
|
481
489
|
const msg = err.message || err.error || '';
|
|
490
|
+
const parsed = parseStack(err.stack, knownComponents);
|
|
482
491
|
let classification = {
|
|
483
492
|
id: `err_${idx}`,
|
|
484
493
|
severity: 'error',
|
|
485
494
|
code: 'ERR_RUNTIME',
|
|
486
495
|
message: msg,
|
|
487
496
|
timestamp: err.timestamp,
|
|
488
|
-
file: err.file || null,
|
|
489
|
-
line: err.line || null,
|
|
490
|
-
|
|
491
|
-
|
|
497
|
+
file: err.file || parsed.file || null,
|
|
498
|
+
line: err.line || parsed.line || null,
|
|
499
|
+
column: parsed.column || null,
|
|
500
|
+
component: err.component || parsed.component || null,
|
|
501
|
+
suggestion: inferSuggestion(msg),
|
|
492
502
|
codeExample: null,
|
|
493
503
|
};
|
|
494
504
|
|
|
@@ -612,12 +622,14 @@ export function registerTools(server, bridge) {
|
|
|
612
622
|
'Set a signal value in the running app. Returns previous and new values.',
|
|
613
623
|
{
|
|
614
624
|
signalId: z.number().describe('The signal ID to update (from what_signals)'),
|
|
615
|
-
value: z.any().describe('The new value to set (JSON-compatible)'),
|
|
625
|
+
value: z.any().describe('The new value to set (JSON-compatible). Note: string values that look like numbers/booleans/JSON are auto-parsed (e.g. "42" becomes 42). Set rawString: true to keep the literal string.'),
|
|
626
|
+
rawString: z.boolean().optional().describe('When true, skip auto-coercion and write the value as-is (useful for string values like "42" that would otherwise be parsed as numbers)'),
|
|
616
627
|
},
|
|
617
|
-
async ({ signalId, value }) => {
|
|
628
|
+
async ({ signalId, value, rawString }) => {
|
|
618
629
|
if (!bridge.isConnected()) return noConnection('what_set_signal');
|
|
619
630
|
try {
|
|
620
|
-
const
|
|
631
|
+
const parsedValue = rawString ? value : coerceJsonValue(value);
|
|
632
|
+
const result = await bridge.sendCommand('set-signal', { signalId, value: parsedValue });
|
|
621
633
|
if (result.error) return error(result.error);
|
|
622
634
|
|
|
623
635
|
const summary = `Signal ${signalId} updated. Previous: ${JSON.stringify(result.previousValue)}, New: ${JSON.stringify(result.newValue ?? value)}`;
|
|
@@ -757,3 +769,147 @@ function error(message) {
|
|
|
757
769
|
isError: true,
|
|
758
770
|
};
|
|
759
771
|
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Parse a JS error stack trace and extract:
|
|
775
|
+
* - file/line/column of the topmost USER frame (skipping framework internals)
|
|
776
|
+
* - component name matched against the live components registry
|
|
777
|
+
*
|
|
778
|
+
* Stack formats vary by engine. We handle V8/Chrome (the only target since
|
|
779
|
+
* the app runs in a browser):
|
|
780
|
+
* " at FunctionName (file.js:12:34)"
|
|
781
|
+
* " at file.js:12:34"
|
|
782
|
+
*
|
|
783
|
+
* Frames are skipped if they reference framework internals (what-framework,
|
|
784
|
+
* what-core, what-devtools, node_modules, vite/, /@id/, internal anonymous).
|
|
785
|
+
*/
|
|
786
|
+
function parseStack(stack, knownComponents = []) {
|
|
787
|
+
const out = { file: null, line: null, column: null, component: null };
|
|
788
|
+
if (!stack || typeof stack !== 'string') return out;
|
|
789
|
+
|
|
790
|
+
const lines = stack.split('\n');
|
|
791
|
+
const skipPatterns = [
|
|
792
|
+
/what-framework/,
|
|
793
|
+
/what-core/,
|
|
794
|
+
/what-devtools/,
|
|
795
|
+
/node_modules/,
|
|
796
|
+
/\/vite\//,
|
|
797
|
+
/\/@id\//,
|
|
798
|
+
/<anonymous>/,
|
|
799
|
+
/^Error[: ]/,
|
|
800
|
+
];
|
|
801
|
+
|
|
802
|
+
const knownNames = new Set(knownComponents.map(c => c.name));
|
|
803
|
+
|
|
804
|
+
for (const rawLine of lines) {
|
|
805
|
+
const line = rawLine.trim();
|
|
806
|
+
if (!line.startsWith('at ')) continue;
|
|
807
|
+
|
|
808
|
+
// Try to match: at FunctionName (path:line:col) OR at path:line:col
|
|
809
|
+
const withFn = line.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/);
|
|
810
|
+
const noFn = line.match(/^at\s+(.+):(\d+):(\d+)$/);
|
|
811
|
+
|
|
812
|
+
const fnName = withFn ? withFn[1] : null;
|
|
813
|
+
const path = withFn ? withFn[2] : noFn ? noFn[1] : null;
|
|
814
|
+
const ln = withFn ? Number(withFn[3]) : noFn ? Number(noFn[2]) : null;
|
|
815
|
+
const col = withFn ? Number(withFn[4]) : noFn ? Number(noFn[3]) : null;
|
|
816
|
+
|
|
817
|
+
if (!path) continue;
|
|
818
|
+
|
|
819
|
+
// Match component name from function frame against known components.
|
|
820
|
+
// Pulls out just the bare identifier, e.g. "TaskList" from "Object.TaskList"
|
|
821
|
+
// or "TaskList.handleClick".
|
|
822
|
+
if (!out.component && fnName) {
|
|
823
|
+
const tokens = fnName.split(/[.\s]/);
|
|
824
|
+
for (const tok of tokens) {
|
|
825
|
+
if (knownNames.has(tok)) { out.component = tok; break; }
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// First non-skipped frame wins for file/line/column.
|
|
830
|
+
if (out.file == null) {
|
|
831
|
+
const skip = skipPatterns.some(re => re.test(path));
|
|
832
|
+
if (!skip) {
|
|
833
|
+
out.file = path;
|
|
834
|
+
out.line = ln;
|
|
835
|
+
out.column = col;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Infer a more specific suggestion from a runtime error message before any
|
|
844
|
+
* pattern-specific overrides apply. Catches common JS mistakes that the
|
|
845
|
+
* What-Framework-specific pattern matchers below don't cover.
|
|
846
|
+
*/
|
|
847
|
+
function inferSuggestion(msg) {
|
|
848
|
+
if (!msg) return 'Check the stack trace and component context for more details.';
|
|
849
|
+
// ReferenceError
|
|
850
|
+
if (/is not defined$/.test(msg)) {
|
|
851
|
+
const m = msg.match(/^(\w+) is not defined/);
|
|
852
|
+
const name = m?.[1] || 'identifier';
|
|
853
|
+
return `'${name}' is referenced before it is declared/imported. Check for a missing import or a typo. If it's a hook value, ensure the binding is in scope.`;
|
|
854
|
+
}
|
|
855
|
+
// TypeError: ... is not a function
|
|
856
|
+
if (/is not a function$/.test(msg)) {
|
|
857
|
+
const m = msg.match(/^(.+?) is not a function/);
|
|
858
|
+
const name = m?.[1] || 'value';
|
|
859
|
+
return `'${name}' is not callable. Common causes: a signal-returning value used like a non-signal, an undefined import, or a typo in the name. If '${name}' is a signal, you must call it with () to read its value.`;
|
|
860
|
+
}
|
|
861
|
+
// Cannot read properties of undefined/null
|
|
862
|
+
if (/Cannot read propert(?:y|ies) of (undefined|null)/.test(msg)) {
|
|
863
|
+
const m = msg.match(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '(.+?)'\)/);
|
|
864
|
+
if (m) {
|
|
865
|
+
return `Tried to read '.${m[2]}' on ${m[1]}. Guard the access (e.g. \`value?.${m[2]}\`), or ensure the signal/prop has been initialised before this code runs.`;
|
|
866
|
+
}
|
|
867
|
+
return 'Tried to access a property on undefined/null. Add an optional-chain (`?.`) or null-check before the access.';
|
|
868
|
+
}
|
|
869
|
+
// Maximum call stack
|
|
870
|
+
if (/Maximum call stack/.test(msg)) {
|
|
871
|
+
return 'Infinite recursion detected. Usually an effect writes to a signal it reads — wrap the read in untrack(), or move the write into a different effect.';
|
|
872
|
+
}
|
|
873
|
+
// Assignment to constant variable
|
|
874
|
+
if (/Assignment to constant variable/.test(msg)) {
|
|
875
|
+
return 'Tried to reassign a `const`. Signals are constants — to update them, call them as functions: `mySignal(newValue)`, not `mySignal = newValue`.';
|
|
876
|
+
}
|
|
877
|
+
return 'Check the stack trace and component context for more details.';
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Coerce a possibly-stringified JSON value into its native form.
|
|
882
|
+
*
|
|
883
|
+
* Agents and some MCP clients pass complex values as JSON-encoded strings
|
|
884
|
+
* (e.g. `'[{"id":1}]'` instead of `[{id: 1}]`). Without this, the receiver
|
|
885
|
+
* would store the literal string and downstream iteration/rendering breaks
|
|
886
|
+
* (mapArray iterates the string's chars, signals hold "42" instead of 42).
|
|
887
|
+
*
|
|
888
|
+
* Heuristic: only parse strings whose first non-whitespace char is one of
|
|
889
|
+
* `{ [ " t f n -` or 0-9. We intentionally do NOT parse arbitrary string
|
|
890
|
+
* content like "hello" because users may legitimately want to set a string
|
|
891
|
+
* value. Quoted JSON strings like `'"hello"'` and `'true'` etc are detected
|
|
892
|
+
* by JSON.parse succeeding on them; if the parse changes the type
|
|
893
|
+
* meaningfully (object/array/boolean/number/null), we accept it. For plain
|
|
894
|
+
* string content (`hello`), JSON.parse fails and we keep the original.
|
|
895
|
+
*/
|
|
896
|
+
function coerceJsonValue(value) {
|
|
897
|
+
if (typeof value !== 'string') return value;
|
|
898
|
+
const trimmed = value.trim();
|
|
899
|
+
if (trimmed.length === 0) return value;
|
|
900
|
+
const first = trimmed[0];
|
|
901
|
+
const looksLikeJson =
|
|
902
|
+
first === '{' || first === '[' || first === '"' ||
|
|
903
|
+
first === 't' || first === 'f' || first === 'n' ||
|
|
904
|
+
first === '-' || (first >= '0' && first <= '9');
|
|
905
|
+
if (!looksLikeJson) return value;
|
|
906
|
+
try {
|
|
907
|
+
const parsed = JSON.parse(trimmed);
|
|
908
|
+
// Only accept the parse if it changes the type to a non-string — keeps
|
|
909
|
+
// user-supplied strings intact while catching double-stringified payloads.
|
|
910
|
+
if (typeof parsed !== 'string') return parsed;
|
|
911
|
+
return value;
|
|
912
|
+
} catch {
|
|
913
|
+
return value;
|
|
914
|
+
}
|
|
915
|
+
}
|
package/src/vite-plugin.js
CHANGED
|
@@ -32,23 +32,56 @@ function resolveToken(explicitToken) {
|
|
|
32
32
|
return '';
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Virtual module id served via Vite's `\0` convention.
|
|
36
|
+
// Resolved IDs starting with `\0` are hidden from the file system and recognised
|
|
37
|
+
// by Vite as plugin-owned modules. We expose them to the browser via the
|
|
38
|
+
// `/@id/<resolved-id>` URL convention so `<script src>` can request them.
|
|
39
|
+
const VIRTUAL_BOOTSTRAP_ID = 'virtual:what-devtools-mcp/bootstrap';
|
|
40
|
+
const RESOLVED_BOOTSTRAP_ID = '\0' + VIRTUAL_BOOTSTRAP_ID;
|
|
41
|
+
// Vite encodes `\0` as `__x00__` in `/@id/` URLs — stable since Vite 2 (2021).
|
|
42
|
+
const BROWSER_BOOTSTRAP_URL = '/@id/__x00__' + VIRTUAL_BOOTSTRAP_ID;
|
|
43
|
+
|
|
35
44
|
export default function whatDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
36
45
|
return {
|
|
37
46
|
name: 'what-devtools-mcp',
|
|
38
47
|
apply: 'serve',
|
|
39
|
-
|
|
48
|
+
|
|
49
|
+
// Resolve the virtual module so Vite knows we own it.
|
|
50
|
+
resolveId(id) {
|
|
51
|
+
if (id === VIRTUAL_BOOTSTRAP_ID || id === RESOLVED_BOOTSTRAP_ID) {
|
|
52
|
+
return RESOLVED_BOOTSTRAP_ID;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Load the bootstrap source. Because this is a real JS module that goes
|
|
58
|
+
// through Vite's transform pipeline, bare specifiers like `what-core` get
|
|
59
|
+
// properly rewritten to dev-server URLs — unlike inline <script type=module>
|
|
60
|
+
// tags injected via transformIndexHtml, which Vite does not transform.
|
|
61
|
+
load(id) {
|
|
62
|
+
if (id !== RESOLVED_BOOTSTRAP_ID) return null;
|
|
40
63
|
const tokenValue = resolveToken(token);
|
|
41
|
-
return
|
|
42
|
-
'
|
|
43
|
-
|
|
44
|
-
import
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
return [
|
|
65
|
+
`import * as core from 'what-core';`,
|
|
66
|
+
`import { installDevTools } from 'what-devtools';`,
|
|
67
|
+
`import { connectDevToolsMCP } from 'what-devtools-mcp/client';`,
|
|
68
|
+
`installDevTools(core);`,
|
|
69
|
+
`connectDevToolsMCP({ port: ${port}, token: ${JSON.stringify(tokenValue)} });`,
|
|
70
|
+
].join('\n');
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
transformIndexHtml() {
|
|
74
|
+
// Inject a <script src> that points at the virtual module. The browser
|
|
75
|
+
// fetches `/@id/__x00__virtual:what-devtools-mcp/bootstrap`, Vite serves
|
|
76
|
+
// the transformed bootstrap (bare specifiers resolved), and everything
|
|
77
|
+
// loads correctly without "Failed to resolve module specifier" errors.
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
tag: 'script',
|
|
81
|
+
attrs: { type: 'module', src: BROWSER_BOOTSTRAP_URL },
|
|
82
|
+
injectTo: 'body',
|
|
83
|
+
},
|
|
84
|
+
];
|
|
52
85
|
},
|
|
53
86
|
};
|
|
54
87
|
}
|