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/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
- component: err.component || null,
491
- suggestion: 'Check the stack trace and component context for more details.',
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 result = await bridge.sendCommand('set-signal', { signalId, value });
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
+ }
@@ -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
- transformIndexHtml(html) {
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 html.replace(
42
- '</body>',
43
- `<script type="module">
44
- import * as core from 'what-core';
45
- import { installDevTools } from 'what-devtools';
46
- import { connectDevToolsMCP } from 'what-devtools-mcp/client';
47
- installDevTools(core);
48
- connectDevToolsMCP({ port: ${port}, token: ${JSON.stringify(tokenValue)} });
49
- </script>
50
- </body>`
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
  }