what-devtools-mcp 0.8.4 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-devtools-mcp",
3
- "version": "0.8.4",
3
+ "version": "0.10.0",
4
4
  "description": "MCP server bridging AI agents to live What Framework app state via WebSocket",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./src/index.js",
11
11
  "./client": "./src/client.js",
12
+ "./vite": "./src/vite-plugin.js",
12
13
  "./vite-plugin": "./src/vite-plugin.js"
13
14
  },
14
15
  "files": [
@@ -16,7 +17,7 @@
16
17
  ],
17
18
  "dependencies": {
18
19
  "@modelcontextprotocol/sdk": "^1.0.0",
19
- "ws": "^8.0.0",
20
+ "ws": "^8.18.0",
20
21
  "zod": "^3.25.0"
21
22
  },
22
23
  "peerDependencies": {
@@ -29,5 +30,14 @@
29
30
  "model-context-protocol",
30
31
  "ai-debugging"
31
32
  ],
32
- "license": "MIT"
33
+ "author": "ZVN DEV (https://zvndev.com)",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/CelsianJs/what-framework"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/CelsianJs/what-framework/issues"
41
+ },
42
+ "homepage": "https://whatfw.com"
33
43
  }
package/src/bridge.js CHANGED
@@ -33,9 +33,26 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
33
33
  // Set WHAT_MCP_TOKEN to share the same token between bridge and Vite plugin.
34
34
  const authToken = process.env.WHAT_MCP_TOKEN || randomBytes(24).toString('hex');
35
35
 
36
+ // Only browser pages served from a loopback origin may talk to the bridge.
37
+ // This is the key defense against the "confused deputy" attack where a page
38
+ // on evil.com (open in the same browser as `what dev`) steals the token and
39
+ // drives set-signal/navigate/eval against the live app. Requests with no
40
+ // Origin header come from non-browser local processes (the MCP server's own
41
+ // client, curl) and are gated by the token instead. (AUDIT-2026-06-06 C6)
42
+ function isAllowedOrigin(origin) {
43
+ if (!origin) return true; // non-browser client; token is the gate
44
+ try {
45
+ const { hostname } = new URL(origin);
46
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
36
52
  const wss = new WebSocketServer({ host, port, verifyClient: ({ req }) => {
37
- // Require a valid token query parameter to connect
53
+ // Require BOTH a loopback origin (defense in depth) AND a valid token.
38
54
  try {
55
+ if (!isAllowedOrigin(req.headers.origin)) return false;
39
56
  const url = new URL(req.url, `http://${host}:${port}`);
40
57
  return url.searchParams.get('token') === authToken;
41
58
  } catch {
@@ -54,8 +71,19 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
54
71
 
55
72
  const discoveryPort = port + 1;
56
73
  const httpServer = createServer((req, res) => {
57
- // CORS headers so the browser can fetch from any origin
58
- res.setHeader('Access-Control-Allow-Origin', '*');
74
+ const origin = req.headers.origin;
75
+ // NEVER use a wildcard ACAO here — it would let any website read the token
76
+ // and then connect to the bridge. Echo the origin back only for loopback
77
+ // origins; reject token reads from any other origin. (AUDIT-2026-06-06 C6)
78
+ if (!isAllowedOrigin(origin)) {
79
+ res.writeHead(403);
80
+ res.end('Forbidden origin');
81
+ return;
82
+ }
83
+ if (origin) {
84
+ res.setHeader('Access-Control-Allow-Origin', origin);
85
+ res.setHeader('Vary', 'Origin');
86
+ }
59
87
  res.setHeader('Access-Control-Allow-Methods', 'GET');
60
88
  res.setHeader('Cache-Control', 'no-store');
61
89
 
@@ -86,7 +114,7 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
86
114
  }
87
115
 
88
116
  console.error(`[what-devtools-mcp] Bridge listening on ws://${host}:${port}`);
89
- console.error(`[what-devtools-mcp] Auth token: ${authToken}`);
117
+ console.error(`[what-devtools-mcp] Bridge started on port ${port} (auth enabled)`);
90
118
 
91
119
  wss.on('connection', (ws) => {
92
120
  browserSocket = ws;
@@ -133,12 +133,14 @@ export async function handleExtendedCommand(command, args, devtools) {
133
133
 
134
134
  // Allow safe read-only expressions without the unsafe flag
135
135
  const code = (args.code || '').trim();
136
- const isSafeRead = /^[\w.[\]'"]+$/.test(code) || // property access: document.title, window.innerWidth
137
- /^typeof\s+\w+/.test(code) || // typeof checks
138
- /^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(code) ||
139
- /^window\.(innerWidth|innerHeight|devicePixelRatio|screen\.\w+)$/.test(code) ||
140
- /^navigator\.\w+$/.test(code) ||
141
- /^location\.\w+$/.test(code);
136
+ // Strict safe-read: only allow dotted property access on known safe globals.
137
+ const SAFE_GLOBALS = new Set(['document', 'window', 'navigator', 'location', 'screen', 'performance', 'console']);
138
+ const segments = code.split('.');
139
+ const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
140
+ const PROTO_DENYLIST = new Set(['constructor', 'prototype', '__proto__']);
141
+ const isSafeRead = segments.length >= 2 &&
142
+ SAFE_GLOBALS.has(segments[0]) &&
143
+ segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s));
142
144
 
143
145
  if (!evalEnabled && !isSafeRead) {
144
146
  return {
@@ -148,9 +150,11 @@ export async function handleExtendedCommand(command, args, devtools) {
148
150
 
149
151
  const start = performance.now();
150
152
  try {
151
- // Use Function constructor to execute in global scope
153
+ // Execute the SAME validated string that passed the safe-read check
154
+ // above (not the untrimmed args.code) to avoid a validate-one/run-another
155
+ // mismatch. (AUDIT-2026-06-06 m10)
152
156
  // eslint-disable-next-line no-new-func
153
- const fn = new Function(args.code);
157
+ const fn = new Function(code);
154
158
  const raw = fn();
155
159
  const elapsed = performance.now() - start;
156
160
  return {
@@ -293,6 +297,14 @@ export async function handleExtendedCommand(command, args, devtools) {
293
297
  return { error: 'No path provided' };
294
298
  }
295
299
 
300
+ // Validate URL — reject dangerous protocols (server-side + browser-side defense)
301
+ const trimmedPath = String(path).trim();
302
+ const normalizedUrl = trimmedPath.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
303
+ const isRelativePath = /^[/.#?]/.test(trimmedPath) || !trimmedPath.includes(':');
304
+ if (!isRelativePath && !/^https?:/.test(normalizedUrl)) {
305
+ return { error: `Blocked navigation to unsafe URL: "${path}"` };
306
+ }
307
+
296
308
  try {
297
309
  // Prefer What Router's navigate() if available
298
310
  const core = window.__WHAT_CORE__;
@@ -539,14 +551,26 @@ export async function handleExtendedCommand(command, args, devtools) {
539
551
  // page-map — Structured map of the entire visible page
540
552
  // -------------------------------------------------------------------------
541
553
  case 'page-map': {
554
+ // Each category gets its own budget so a page with many landmarks
555
+ // doesn't starve out headings or components. Previous behaviour shared
556
+ // one `count` across all sections — a page with >maxElements landmarks
557
+ // returned headings: [] and components: [], which agents read as
558
+ // "the page has no headings / no components".
542
559
  const maxElements = args?.maxElements || 200;
543
- let count = 0;
560
+ const perCategory = Math.max(50, Math.floor(maxElements / 2));
561
+
562
+ function rectOf(el) {
563
+ // Comment nodes / detached nodes have no rect; element walk handles that.
564
+ if (!el || el.nodeType !== 1) return { x: 0, y: 0, w: 0, h: 0 };
565
+ const r = el.getBoundingClientRect();
566
+ return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
567
+ }
544
568
 
545
569
  // Landmarks
546
570
  const landmarks = [];
547
571
  const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
548
572
  for (const el of landmarkEls) {
549
- if (count >= maxElements) break;
573
+ if (landmarks.length >= perCategory) break;
550
574
  const rect = el.getBoundingClientRect();
551
575
  if (rect.width === 0 && rect.height === 0) continue;
552
576
  landmarks.push({
@@ -554,16 +578,15 @@ export async function handleExtendedCommand(command, args, devtools) {
554
578
  role: el.getAttribute('role') || undefined,
555
579
  id: el.id || undefined,
556
580
  text: (el.textContent || '').trim().substring(0, 50),
557
- rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
581
+ rect: rectOf(el),
558
582
  });
559
- count++;
560
583
  }
561
584
 
562
585
  // Interactive elements
563
586
  const interactives = [];
564
587
  const interactiveEls = document.querySelectorAll('button, a[href], input, select, textarea, [role=button], [role=link], [contenteditable]');
565
588
  for (const el of interactiveEls) {
566
- if (count >= maxElements) break;
589
+ if (interactives.length >= perCategory) break;
567
590
  const rect = el.getBoundingClientRect();
568
591
  if (rect.width === 0 && rect.height === 0) continue;
569
592
  const label = el.getAttribute('aria-label') || el.textContent?.trim().substring(0, 40) || el.getAttribute('placeholder') || el.getAttribute('name') || '';
@@ -572,39 +595,41 @@ export async function handleExtendedCommand(command, args, devtools) {
572
595
  type: el.getAttribute('type') || undefined,
573
596
  label: label || '(unlabeled)',
574
597
  disabled: el.disabled || undefined,
575
- rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
598
+ rect: rectOf(el),
576
599
  });
577
- count++;
578
600
  }
579
601
 
580
- // Headings
602
+ // Headings — heading lists are nearly always small so we don't gate
603
+ // on visibility (some agents use display:none headings for SR-only nav).
581
604
  const headings = [];
582
605
  const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
583
606
  for (const el of headingEls) {
584
- if (count >= maxElements) break;
607
+ if (headings.length >= perCategory) break;
608
+ const text = (el.textContent || '').trim().substring(0, 80);
609
+ if (!text) continue; // skip truly empty headings
585
610
  headings.push({
586
- level: parseInt(el.tagName[1]),
587
- text: (el.textContent || '').trim().substring(0, 80),
611
+ level: parseInt(el.tagName[1], 10),
612
+ text,
613
+ id: el.id || undefined,
588
614
  });
589
- count++;
590
615
  }
591
616
 
592
- // WhatFW component boundaries
617
+ // WhatFW component boundaries. Comment-node boundaries have no
618
+ // dimensions, so a strict zero-rect check was filtering everything
619
+ // out. Walk the registry once and emit every component, using the
620
+ // resolved element's rect when available.
593
621
  const components = [];
594
622
  const registries = devtools?._registries;
595
623
  if (registries?.components) {
596
624
  for (const [id, entry] of registries.components) {
597
- if (count >= maxElements) break;
625
+ if (components.length >= perCategory) break;
598
626
  const compEl = getComponentElement(entry);
599
- if (!compEl) continue;
600
- const rect = compEl.getBoundingClientRect();
601
- if (rect.width === 0 && rect.height === 0) continue;
602
627
  components.push({
603
628
  id,
604
629
  name: entry.name,
605
- rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
630
+ rect: compEl ? rectOf(compEl) : { x: 0, y: 0, w: 0, h: 0 },
631
+ hasElement: !!compEl,
606
632
  });
607
- count++;
608
633
  }
609
634
  }
610
635
 
@@ -614,7 +639,7 @@ export async function handleExtendedCommand(command, args, devtools) {
614
639
  interactives,
615
640
  headings,
616
641
  components,
617
- totalElements: count,
642
+ totalElements: landmarks.length + interactives.length + headings.length + components.length,
618
643
  };
619
644
  }
620
645
 
package/src/client.js CHANGED
@@ -207,7 +207,24 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
207
207
  break;
208
208
  }
209
209
  case 'set-signal': {
210
- const { signalId, value } = args || {};
210
+ const { signalId, value: rawValue } = args || {};
211
+ // Defense-in-depth: also coerce stringified-JSON here in case an
212
+ // older MCP server forwards the raw string. See tools.js coerceJsonValue.
213
+ let value = rawValue;
214
+ if (typeof rawValue === 'string') {
215
+ const t = rawValue.trim();
216
+ const first = t[0];
217
+ if (t.length > 0 && (
218
+ first === '{' || first === '[' ||
219
+ first === 't' || first === 'f' || first === 'n' ||
220
+ first === '-' || (first >= '0' && first <= '9')
221
+ )) {
222
+ try {
223
+ const parsed = JSON.parse(t);
224
+ if (typeof parsed !== 'string') value = parsed;
225
+ } catch {}
226
+ }
227
+ }
211
228
  const registries = devtools?._registries;
212
229
  if (registries?.signals) {
213
230
  const entry = registries.signals.get(signalId);
@@ -262,7 +262,7 @@ const LINT_RULES = [
262
262
  code: 'ERR_MISSING_CLEANUP',
263
263
  message: `Effect sets up ${resource} but does not return a cleanup function — memory leak risk.`,
264
264
  line,
265
- suggestedFix: `Return a cleanup function: return () => remove${resource === 'event listener' ? 'EventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
265
+ suggestedFix: `Return a cleanup function: return () => ${resource === 'event listener' ? 'removeEventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
266
266
  });
267
267
  }
268
268
  }
@@ -431,6 +431,136 @@ const LINT_RULES = [
431
431
  return issues;
432
432
  },
433
433
  },
434
+ {
435
+ // -----------------------------------------------------------------------
436
+ // destructured-props-lose-reactivity
437
+ //
438
+ // What Framework components run ONCE — the function body is not re-run on
439
+ // prop change. The reactive props proxy auto-tracks reads via `props.foo`,
440
+ // but `const { foo } = props` snapshots the current value and detaches it
441
+ // from the proxy. Subsequent updates from the parent are invisible.
442
+ // -----------------------------------------------------------------------
443
+ id: 'destructured-props-lose-reactivity',
444
+ code: 'ERR_DESTRUCTURED_PROPS',
445
+ severity: 'warning',
446
+ test(code) {
447
+ const issues = [];
448
+ // Find component functions whose first param is `props` (any binding).
449
+ // We support both `function Foo(props) {` and `const Foo = (props) =>`.
450
+ const componentPatterns = [
451
+ /function\s+([A-Z]\w*)\s*\(\s*(\w+)\s*\)\s*\{/g,
452
+ /(?:const|let)\s+([A-Z]\w*)\s*=\s*\(\s*(\w+)\s*\)\s*=>/g,
453
+ ];
454
+ for (const pattern of componentPatterns) {
455
+ let compMatch;
456
+ while ((compMatch = pattern.exec(code)) !== null) {
457
+ const propsBinding = compMatch[2];
458
+ // Skip if the param is already destructured at the signature
459
+ // (that's a separate, more legible smell; we focus on body-level
460
+ // destructuring inside the component).
461
+ if (propsBinding === 'props' || /^[a-z]/.test(propsBinding)) {
462
+ // Walk forward and find the component body extent (braces).
463
+ const startIdx = compMatch.index + compMatch[0].length;
464
+ let braceDepth = 1;
465
+ let bodyEnd = startIdx;
466
+ for (let i = startIdx; i < code.length && braceDepth > 0; i++) {
467
+ if (code[i] === '{') braceDepth++;
468
+ if (code[i] === '}') braceDepth--;
469
+ bodyEnd = i;
470
+ }
471
+ const body = code.slice(startIdx, bodyEnd);
472
+ // Find: const|let { ... } = props (the actual binding name).
473
+ const destructPattern = new RegExp(
474
+ `(?:const|let)\\s*\\{([^}]+)\\}\\s*=\\s*${propsBinding}\\b`,
475
+ 'g'
476
+ );
477
+ let dm;
478
+ while ((dm = destructPattern.exec(body)) !== null) {
479
+ const fields = dm[1].split(',').map(s => s.trim().split(/[:=]/)[0].trim()).filter(Boolean);
480
+ const line = code.slice(0, startIdx + dm.index).split('\n').length;
481
+ issues.push({
482
+ severity: 'warning',
483
+ code: 'ERR_DESTRUCTURED_PROPS',
484
+ message: `Destructuring '${propsBinding}' in the component body snapshots props and loses reactivity. Components run ONCE — '${propsBinding}.${fields[0] || 'foo'}' tracks via the props proxy, but '{ ${fields.join(', ')} } = ${propsBinding}' does not.`,
485
+ line,
486
+ suggestedFix: `Read props directly inside JSX or effects: \`${propsBinding}.${fields[0] || 'foo'}\` — or wrap each in an accessor: const ${fields[0] || 'foo'} = () => ${propsBinding}.${fields[0] || 'foo'}.`,
487
+ });
488
+ }
489
+ }
490
+ }
491
+ }
492
+ return issues;
493
+ },
494
+ },
495
+ {
496
+ // -----------------------------------------------------------------------
497
+ // module-scope-signal-missing-name
498
+ //
499
+ // Module-scope signals are global state. Without a debug name (second arg
500
+ // to signal()), they appear as `signal_42` in devtools and what_signals,
501
+ // making cross-tool debugging much harder. This is a hint, not an error.
502
+ // -----------------------------------------------------------------------
503
+ id: 'module-scope-signal-missing-name',
504
+ code: 'HINT_SIGNAL_MISSING_NAME',
505
+ severity: 'info',
506
+ test(code) {
507
+ const issues = [];
508
+ // Look for signal/computed declarations at the top level — i.e., not
509
+ // indented (or only minimally) and not inside a function. A simple but
510
+ // reliable heuristic: lines that match `^(?:export\s+)?(?:const|let)
511
+ // \s+\w+\s*=\s*(signal|computed)\s*\(` AND the opening call has only
512
+ // one argument (no comma at the same paren depth before the close).
513
+ const lines = code.split('\n');
514
+ const declRe = /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(signal|computed)\s*\(/;
515
+ // Track simple brace depth so we skip signals declared inside a function.
516
+ let depth = 0;
517
+ for (let i = 0; i < lines.length; i++) {
518
+ const line = lines[i];
519
+ // Pre-line depth determines whether we're at module scope.
520
+ const wasModuleScope = depth === 0;
521
+ // Update depth after — we want to evaluate the line at its starting scope.
522
+ for (let j = 0; j < line.length; j++) {
523
+ if (line[j] === '{') depth++;
524
+ else if (line[j] === '}') depth = Math.max(0, depth - 1);
525
+ }
526
+ if (!wasModuleScope) continue;
527
+ const m = line.match(declRe);
528
+ if (!m) continue;
529
+ const sigName = m[1];
530
+ const kind = m[2];
531
+ // Reconstruct the full call args by walking parens forward.
532
+ const callStart = m.index + m[0].length; // position right after the opening '('
533
+ // The match is on `line` only; rebuild full call across lines.
534
+ let scan = code.indexOf(line, 0);
535
+ // Find absolute position of the opening paren of signal/computed(...)
536
+ const lineStart = code.split('\n').slice(0, i).join('\n').length + (i > 0 ? 1 : 0);
537
+ const openIdx = lineStart + callStart - 1; // points at the '('
538
+ // Walk to matching close.
539
+ let d = 1, j = openIdx + 1, commasAtDepth1 = 0;
540
+ for (; j < code.length && d > 0; j++) {
541
+ const ch = code[j];
542
+ if (ch === '(') d++;
543
+ else if (ch === ')') d--;
544
+ else if (ch === ',' && d === 1) commasAtDepth1++;
545
+ // Treat string literals naively — bail out of this check if we see a
546
+ // backtick, since template literals make this regex unreliable.
547
+ }
548
+ // For signal(), only one arg means no debug name.
549
+ // For computed(), arity is always 1 — skip; debug name is via .debugName?
550
+ if (kind === 'signal' && commasAtDepth1 === 0) {
551
+ issues.push({
552
+ severity: 'info',
553
+ code: 'HINT_SIGNAL_MISSING_NAME',
554
+ message: `Module-scope signal '${sigName}' has no debug name. It will appear as 'signal_N' in devtools and what_signals — agents and humans both find it harder to trace.`,
555
+ line: i + 1,
556
+ suggestedFix: `Add a debug name as the second argument: signal(<initial>, '${sigName}').`,
557
+ });
558
+ }
559
+ void scan;
560
+ }
561
+ return issues;
562
+ },
563
+ },
434
564
  ];
435
565
 
436
566
  // --- Scaffold Templates ---
@@ -641,7 +771,7 @@ export function registerAgentTools(server, bridge) {
641
771
  'Static analysis for What Framework code. Pass a code snippet, get back structured issues with fix suggestions. Works offline — no browser connection needed.',
642
772
  {
643
773
  code: z.string().describe('The What Framework code snippet to analyze'),
644
- 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, signal-write-in-render, missing-key-in-for, prefer-computed-over-effect'),
774
+ 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, signal-write-in-render, missing-key-in-for, prefer-computed-over-effect, destructured-props-lose-reactivity, module-scope-signal-missing-name'),
645
775
  },
646
776
  async ({ code, rules: ruleFilter }) => {
647
777
  let rulesToRun = LINT_RULES;
@@ -896,7 +1026,13 @@ export function registerAgentTools(server, bridge) {
896
1026
  ? `Performance concerns: ${issues.join('; ')}.`
897
1027
  : `Healthy. ${signals.length} signals, ${effects.length} effects, ${components.length} components. ${memoryStr} estimated.`;
898
1028
 
899
- return ok({
1029
+ // Suppress noisy "every signal has 1 subscriber" output — agents would
1030
+ // chase it as a signal. Only emit largestSubscribers when at least one
1031
+ // signal has 2+ subscribers worth investigating.
1032
+ const maxSubs = largestSubscribers.length > 0
1033
+ ? largestSubscribers[0].subscriberCount
1034
+ : 0;
1035
+ const result = {
900
1036
  summary,
901
1037
  counts: {
902
1038
  signals: signals.length,
@@ -904,7 +1040,6 @@ export function registerAgentTools(server, bridge) {
904
1040
  components: components.length,
905
1041
  },
906
1042
  hotEffects,
907
- largestSubscribers,
908
1043
  eventRate,
909
1044
  memoryEstimate: memoryStr,
910
1045
  memoryBytes: totalEstimate,
@@ -914,7 +1049,9 @@ export function registerAgentTools(server, bridge) {
914
1049
  'Consider using batch() to group signal writes.',
915
1050
  'Use computed() for derived values instead of effects.',
916
1051
  ] : [],
917
- });
1052
+ };
1053
+ if (maxSubs >= 2) result.largestSubscribers = largestSubscribers;
1054
+ return ok(result);
918
1055
  }
919
1056
  );
920
1057
 
@@ -996,4 +1133,152 @@ export function registerAgentTools(server, bridge) {
996
1133
  });
997
1134
  }
998
1135
  );
1136
+
1137
+ // -----------------------------------------------------------------------
1138
+ // Tool 6 — what_record_window
1139
+ //
1140
+ // Opens a sampling window (default 1s, max 30s), captures which effects
1141
+ // re-ran during that window, and returns a ranked list — most-fired first.
1142
+ //
1143
+ // Differs from `what_perf` (cumulative runCount since boot) and from
1144
+ // `what_watch` (raw event log). This one is the one-call "which effects
1145
+ // re-ran during this action?" answer, which is what you want when
1146
+ // profiling a single user interaction (drag-drop, button click, route
1147
+ // change). Pair with `what_set_signal` or a user-driven action to scope
1148
+ // the recording.
1149
+ // -----------------------------------------------------------------------
1150
+
1151
+ server.tool(
1152
+ 'what_record_window',
1153
+ 'Sample which effects re-ran during a time window. Captures runCount before and after a configurable duration, then returns a ranked delta. Use this to identify which effects fire during a specific action (e.g., a drag, a click, or a route change). Default 1000ms.',
1154
+ {
1155
+ duration: z.number().optional().default(1000).describe('Sampling window in ms (default: 1000, max: 30000, min: 50)'),
1156
+ topN: z.number().optional().default(20).describe('Maximum number of effects to return (default: 20)'),
1157
+ includeZero: z.boolean().optional().default(false).describe('Include effects that did not re-run (default: false — only changed effects)'),
1158
+ },
1159
+ async ({ duration, topN, includeZero }) => {
1160
+ if (!bridge.isConnected()) return noConnection('what_record_window');
1161
+
1162
+ const ms = Math.min(Math.max(duration ?? 1000, 50), 30000);
1163
+ const limit = Math.min(Math.max(topN ?? 20, 1), 200);
1164
+
1165
+ // ---- Take baseline ----
1166
+ let baseline;
1167
+ try {
1168
+ baseline = await (bridge.refreshSnapshot ? bridge.refreshSnapshot() : bridge.getSnapshot());
1169
+ } catch {
1170
+ baseline = bridge.getSnapshot();
1171
+ }
1172
+ if (!baseline) {
1173
+ return errorResponse('No snapshot available for baseline.', [
1174
+ 'Refresh the browser page so the devtools client registers its state.',
1175
+ ]);
1176
+ }
1177
+
1178
+ const baselineRunCounts = new Map();
1179
+ const baselineEffectMeta = new Map();
1180
+ for (const e of baseline.effects || []) {
1181
+ baselineRunCounts.set(e.id, e.runCount || 0);
1182
+ baselineEffectMeta.set(e.id, {
1183
+ id: e.id,
1184
+ name: e.name || `effect_${e.id}`,
1185
+ componentId: e.componentId,
1186
+ depCount: (e.depSignalIds || e.deps || []).length,
1187
+ });
1188
+ }
1189
+ const baselineEventCount = bridge.getEvents
1190
+ ? bridge.getEvents(Date.now() - 1).length
1191
+ : 0;
1192
+ const startTs = Date.now();
1193
+
1194
+ // ---- Wait for the window ----
1195
+ await new Promise((resolve) => setTimeout(resolve, ms));
1196
+
1197
+ // ---- Take post-window snapshot ----
1198
+ let after;
1199
+ try {
1200
+ after = await (bridge.refreshSnapshot ? bridge.refreshSnapshot() : bridge.getSnapshot());
1201
+ } catch {
1202
+ after = bridge.getSnapshot();
1203
+ }
1204
+ if (!after) {
1205
+ return errorResponse('No snapshot available after the recording window.', [
1206
+ 'The browser may have lost connection mid-recording.',
1207
+ ]);
1208
+ }
1209
+
1210
+ // ---- Compute delta ----
1211
+ const ranked = [];
1212
+ const newEffects = [];
1213
+ const seen = new Set();
1214
+ for (const e of after.effects || []) {
1215
+ seen.add(e.id);
1216
+ const before = baselineRunCounts.get(e.id);
1217
+ const nowCount = e.runCount || 0;
1218
+ if (before === undefined) {
1219
+ // Effect was created during the window
1220
+ newEffects.push({
1221
+ id: e.id,
1222
+ name: e.name || `effect_${e.id}`,
1223
+ componentId: e.componentId,
1224
+ runCount: nowCount,
1225
+ depCount: (e.depSignalIds || e.deps || []).length,
1226
+ });
1227
+ continue;
1228
+ }
1229
+ const delta = nowCount - before;
1230
+ if (delta > 0 || includeZero) {
1231
+ ranked.push({
1232
+ id: e.id,
1233
+ name: e.name || baselineEffectMeta.get(e.id)?.name || `effect_${e.id}`,
1234
+ componentId: e.componentId ?? baselineEffectMeta.get(e.id)?.componentId,
1235
+ runs: delta,
1236
+ totalRuns: nowCount,
1237
+ depCount: (e.depSignalIds || e.deps || []).length,
1238
+ });
1239
+ }
1240
+ }
1241
+ const disposedDuring = [];
1242
+ for (const [id, meta] of baselineEffectMeta) {
1243
+ if (!seen.has(id)) disposedDuring.push(meta);
1244
+ }
1245
+
1246
+ ranked.sort((a, b) => b.runs - a.runs);
1247
+ const top = ranked.slice(0, limit);
1248
+
1249
+ const totalRuns = ranked.reduce((sum, e) => sum + e.runs, 0);
1250
+ const distinctEffects = ranked.length;
1251
+ const eventsDuring = bridge.getEvents
1252
+ ? bridge.getEvents(startTs).length
1253
+ : null;
1254
+
1255
+ const summary = totalRuns === 0
1256
+ ? `No effects re-ran during the ${ms}ms window. App is idle (or no reactive state changed).`
1257
+ : `${totalRuns} effect run${totalRuns !== 1 ? 's' : ''} across ${distinctEffects} distinct effect${distinctEffects !== 1 ? 's' : ''} in ${ms}ms.`;
1258
+
1259
+ const nextSteps = [];
1260
+ if (top.length > 0 && top[0].runs >= 10) {
1261
+ nextSteps.push(`Hot effect "${top[0].name}" ran ${top[0].runs} times — inspect with what_dependency_graph({effectId: ${top[0].id}, direction: "upstream"}).`);
1262
+ }
1263
+ if (newEffects.length > 5) {
1264
+ nextSteps.push(`${newEffects.length} effects were created during the window — likely a re-mount cycle. Check what_diff_snapshot for component churn.`);
1265
+ }
1266
+ if (disposedDuring.length > 0 && newEffects.length > 0) {
1267
+ nextSteps.push(`${disposedDuring.length} effects were disposed and ${newEffects.length} were created — component tree is being torn down and rebuilt.`);
1268
+ }
1269
+
1270
+ return ok({
1271
+ summary,
1272
+ windowMs: ms,
1273
+ totalRuns,
1274
+ distinctEffects,
1275
+ topEffects: top,
1276
+ newEffectsCount: newEffects.length,
1277
+ newEffects: newEffects.slice(0, limit),
1278
+ disposedCount: disposedDuring.length,
1279
+ eventsDuring,
1280
+ nextSteps,
1281
+ });
1282
+ }
1283
+ );
999
1284
  }
@@ -70,6 +70,37 @@ export function registerExtendedTools(server, bridge) {
70
70
  };
71
71
  }
72
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // Helper: framework-wide signal value preview policy.
75
+ //
76
+ // Goal: agents see the SAME shape across every tool that surfaces a signal
77
+ // value (what_signals, what_dependency_graph, what_signal_trace, …).
78
+ //
79
+ // Policy:
80
+ // - Primitives (number/boolean/null/undefined): return as-is.
81
+ // - Strings: full when small, truncated with ellipsis when long.
82
+ // - Arrays/objects: full structure when its JSON stringification fits
83
+ // under PREVIEW_LIMIT chars; otherwise return a truncated JSON string.
84
+ //
85
+ // Threshold chosen to fit "small list of small items" while protecting
86
+ // dep-graph topology output from token blow-ups on huge structures.
87
+ // ---------------------------------------------------------------------------
88
+ const PREVIEW_LIMIT = 100;
89
+ function previewSignalValue(raw) {
90
+ if (raw == null) return raw;
91
+ const t = typeof raw;
92
+ if (t === 'number' || t === 'boolean') return raw;
93
+ if (t === 'string') return raw.length > PREVIEW_LIMIT ? raw.slice(0, PREVIEW_LIMIT) + '…' : raw;
94
+ if (t === 'object') {
95
+ let json;
96
+ try { json = JSON.stringify(raw); } catch { return String(raw); }
97
+ if (json == null) return String(raw);
98
+ if (json.length <= PREVIEW_LIMIT) return raw;
99
+ return json.slice(0, PREVIEW_LIMIT) + '…';
100
+ }
101
+ return String(raw);
102
+ }
103
+
73
104
  // ---------------------------------------------------------------------------
74
105
  // Helper: get a fresh or cached snapshot
75
106
  // ---------------------------------------------------------------------------
@@ -353,16 +384,10 @@ export function registerExtendedTools(server, bridge) {
353
384
  const id = Number(idStr);
354
385
  if (type === 'signal') {
355
386
  const s = signalMap.get(id);
356
- // Truncate values to avoid token waste graph topology is what matters
357
- const raw = s?.value;
358
- let value;
359
- if (raw == null) value = raw;
360
- else if (typeof raw === 'string') value = raw.length > 80 ? raw.slice(0, 80) + '…' : raw;
361
- else if (Array.isArray(raw)) value = `Array(${raw.length})`;
362
- else if (typeof raw === 'object') {
363
- const json = JSON.stringify(raw);
364
- value = json.length > 80 ? json.slice(0, 80) + '…' : raw;
365
- } else value = raw;
387
+ // Use the framework-wide value-preview policyfull values when
388
+ // small, truncated JSON when large. Keeps output consistent with
389
+ // what_signals so agents see the same shape across tools.
390
+ const value = previewSignalValue(s?.value);
366
391
  nodes.push({ type: 'signal', id, name: s?.name || `signal_${id}`, value });
367
392
  } else {
368
393
  const e = effectMap.get(id);
@@ -403,12 +428,15 @@ export function registerExtendedTools(server, bridge) {
403
428
  async ({ code, timeout }) => {
404
429
  // Allow safe read-only property access without the unsafe flag
405
430
  const trimmed = (code || '').trim();
406
- const isSafeRead = /^[\w.[\]'"]+$/.test(trimmed) && !trimmed.includes('=') ||
407
- /^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(trimmed) ||
408
- /^window\.(innerWidth|innerHeight|devicePixelRatio|outerWidth|outerHeight)$/.test(trimmed) ||
409
- /^navigator\.(userAgent|language|platform|onLine|hardwareConcurrency)$/.test(trimmed) ||
410
- /^location\.(href|hostname|pathname|protocol|port|origin)$/.test(trimmed) ||
411
- /^screen\.(width|height|availWidth|availHeight|colorDepth)$/.test(trimmed);
431
+ // Strict safe-read: only allow dotted property access on known safe globals.
432
+ // Each segment must be a simple identifier (no brackets, quotes, or calls).
433
+ const SAFE_GLOBALS = new Set(['document', 'window', 'navigator', 'location', 'screen', 'performance', 'console']);
434
+ const segments = trimmed.split('.');
435
+ const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
436
+ const PROTO_DENYLIST = new Set(['constructor', 'prototype', '__proto__']);
437
+ const isSafeRead = segments.length >= 2 &&
438
+ SAFE_GLOBALS.has(segments[0]) &&
439
+ segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s));
412
440
 
413
441
  if (!unsafeEvalEnabled && !isSafeRead) {
414
442
  return errorResponse(
@@ -833,8 +861,19 @@ export function registerExtendedTools(server, bridge) {
833
861
  async ({ path, replace }) => {
834
862
  if (!bridge.isConnected()) return noConnection('what_navigate');
835
863
 
864
+ // Validate URL — reject dangerous protocols
865
+ const trimmedPath = path.trim();
866
+ const normalized = trimmedPath.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
867
+ const isRelative = /^[/.#?]/.test(trimmedPath) || !trimmedPath.includes(':');
868
+ if (!isRelative && !/^https?:/.test(normalized)) {
869
+ return errorResponse(`Blocked navigation to unsafe URL: "${path}"`, [
870
+ 'Only relative paths (/foo, ./bar, #hash, ?query) and http(s) URLs are allowed.',
871
+ 'javascript:, data:, and vbscript: URLs are rejected for security.',
872
+ ]);
873
+ }
874
+
836
875
  try {
837
- const result = await bridge.sendCommand('navigate', { path, replace });
876
+ const result = await bridge.sendCommand('navigate', { path: trimmedPath, replace });
838
877
  if (result.error) {
839
878
  return errorResponse(result.error, [
840
879
  'Check that the path is valid.',
@@ -860,7 +899,7 @@ export function registerExtendedTools(server, bridge) {
860
899
 
861
900
  server.tool(
862
901
  'what_explain',
863
- 'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool.',
902
+ 'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool. Component IDs are ephemeral — they change on mount/unmount, so re-query what_components after any signal write that may have remounted the tree.',
864
903
  {
865
904
  componentId: z.number().describe('Component ID to explain (from what_components)'),
866
905
  includeDOM: z.boolean().optional().default(true).describe('Include rendered DOM output (default: true)'),
@@ -937,8 +976,9 @@ export function registerExtendedTools(server, bridge) {
937
976
  {
938
977
  signalId: z.number().describe('Signal ID to trace (from what_signals)'),
939
978
  depth: z.number().optional().default(2).describe('Causal chain depth — how many levels of effect->signal->effect to trace (default: 2)'),
979
+ auto_watch_ms: z.number().optional().default(500).describe('If no recent writes were captured, briefly listen for events for this many ms before returning (default: 500, set to 0 to disable).'),
940
980
  },
941
- async ({ signalId, depth }) => {
981
+ async ({ signalId, depth, auto_watch_ms }) => {
942
982
  const { snapshot, err } = await freshSnapshot('what_signal_trace');
943
983
  if (err) return err;
944
984
 
@@ -959,6 +999,21 @@ export function registerExtendedTools(server, bridge) {
959
999
  if (writers.error) writers = { recentWrites: [], totalWrites: 0, note: writers.error };
960
1000
  } catch {}
961
1001
 
1002
+ // Auto-arm what_watch when no writes have been captured yet. The
1003
+ // signal-writer ring buffer is populated by initEventTracking, which
1004
+ // runs on first extended command. If the caller hits what_signal_trace
1005
+ // first, the buffer is empty even though the browser is producing
1006
+ // events. Listen briefly so the user gets a result on the first try
1007
+ // instead of having to manually chain what_watch -> what_signal_trace.
1008
+ const watchMs = Math.min(Math.max(Number(auto_watch_ms) || 0, 0), 5000);
1009
+ if (watchMs > 0 && (!writers.recentWrites || writers.recentWrites.length === 0)) {
1010
+ await new Promise(r => setTimeout(r, watchMs));
1011
+ try {
1012
+ const refetched = await bridge.sendCommand('get-signal-writers', { signalId }, 5000);
1013
+ if (refetched && !refetched.error) writers = refetched;
1014
+ } catch {}
1015
+ }
1016
+
962
1017
  // Build causal chain
963
1018
  // For each writer effect, find what signals it depends on
964
1019
  const chain = [];
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
  }