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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-devtools-mcp",
3
- "version": "0.8.4",
3
+ "version": "0.11.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
@@ -13,7 +13,7 @@ import { join } from 'path';
13
13
  const MAX_EVENT_LOG = 1000;
14
14
  const MAX_ERROR_LOG = 100;
15
15
 
16
- export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
16
+ export function createBridge({ port = 9229, host = '127.0.0.1', onError } = {}) {
17
17
  let latestSnapshot = null;
18
18
  const eventLog = [];
19
19
  const errorLog = [];
@@ -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 {
@@ -43,6 +60,30 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
43
60
  }
44
61
  }});
45
62
 
63
+ // The WebSocketServer binds the port immediately on construction. Without an
64
+ // 'error' listener, an EADDRINUSE (very likely — 9229 is also Node's default
65
+ // --inspect port) is emitted as an unhandled 'error' event, which an
66
+ // EventEmitter rethrows and hard-crashes the whole process. The bridge IS the
67
+ // server, so unlike the optional HTTP discovery port we cannot silently carry
68
+ // on with a dead WS server. Strategy: gracefully exit(0) with an actionable
69
+ // message so the launcher (e.g. `what dev`) sees a clean shutdown rather than
70
+ // an uncaught-exception stack trace. Callers (tests) can pass `onError` to
71
+ // intercept instead of exiting.
72
+ wss.on('error', (err) => {
73
+ if (err && err.code === 'EADDRINUSE') {
74
+ const inspectNote = port === 9229 ? ' (note: 9229 is also Node\'s default --inspect port)' : '';
75
+ console.error(`[what-devtools-mcp] port ${port} in use — set WHAT_MCP_PORT to a free port or stop the process using it${inspectNote}.`);
76
+ } else {
77
+ console.error(`[what-devtools-mcp] bridge WebSocket server error: ${err && err.message ? err.message : err}`);
78
+ }
79
+ if (typeof onError === 'function') {
80
+ onError(err);
81
+ } else {
82
+ // Graceful exit: the bridge cannot function without its WS port.
83
+ process.exit(0);
84
+ }
85
+ });
86
+
46
87
  // --- Token Discovery ---
47
88
  // Two mechanisms so the browser client can find the token automatically:
48
89
  //
@@ -54,8 +95,19 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
54
95
 
55
96
  const discoveryPort = port + 1;
56
97
  const httpServer = createServer((req, res) => {
57
- // CORS headers so the browser can fetch from any origin
58
- res.setHeader('Access-Control-Allow-Origin', '*');
98
+ const origin = req.headers.origin;
99
+ // NEVER use a wildcard ACAO here — it would let any website read the token
100
+ // and then connect to the bridge. Echo the origin back only for loopback
101
+ // origins; reject token reads from any other origin. (AUDIT-2026-06-06 C6)
102
+ if (!isAllowedOrigin(origin)) {
103
+ res.writeHead(403);
104
+ res.end('Forbidden origin');
105
+ return;
106
+ }
107
+ if (origin) {
108
+ res.setHeader('Access-Control-Allow-Origin', origin);
109
+ res.setHeader('Vary', 'Origin');
110
+ }
59
111
  res.setHeader('Access-Control-Allow-Methods', 'GET');
60
112
  res.setHeader('Cache-Control', 'no-store');
61
113
 
@@ -86,7 +138,7 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
86
138
  }
87
139
 
88
140
  console.error(`[what-devtools-mcp] Bridge listening on ws://${host}:${port}`);
89
- console.error(`[what-devtools-mcp] Auth token: ${authToken}`);
141
+ console.error(`[what-devtools-mcp] Bridge started on port ${port} (auth enabled)`);
90
142
 
91
143
  wss.on('connection', (ws) => {
92
144
  browserSocket = ws;
@@ -133,12 +133,21 @@ 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
+ // Sensitive property paths: reads that exfiltrate secrets/state must
142
+ // require the explicit unsafe flag (document.cookie, window.localStorage...).
143
+ // Mirror of SENSITIVE_PATHS in tools-extended.js what_eval.
144
+ const SENSITIVE_PATHS = new Set([
145
+ 'cookie', 'localStorage', 'sessionStorage', 'indexedDB', 'credentials',
146
+ 'geolocation', 'clipboard', 'serviceWorker', 'caches', 'opener',
147
+ ]);
148
+ const isSafeRead = segments.length >= 2 &&
149
+ SAFE_GLOBALS.has(segments[0]) &&
150
+ segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s) && !SENSITIVE_PATHS.has(s));
142
151
 
143
152
  if (!evalEnabled && !isSafeRead) {
144
153
  return {
@@ -148,9 +157,11 @@ export async function handleExtendedCommand(command, args, devtools) {
148
157
 
149
158
  const start = performance.now();
150
159
  try {
151
- // Use Function constructor to execute in global scope
160
+ // Execute the SAME validated string that passed the safe-read check
161
+ // above (not the untrimmed args.code) to avoid a validate-one/run-another
162
+ // mismatch. (AUDIT-2026-06-06 m10)
152
163
  // eslint-disable-next-line no-new-func
153
- const fn = new Function(args.code);
164
+ const fn = new Function(code);
154
165
  const raw = fn();
155
166
  const elapsed = performance.now() - start;
156
167
  return {
@@ -293,6 +304,14 @@ export async function handleExtendedCommand(command, args, devtools) {
293
304
  return { error: 'No path provided' };
294
305
  }
295
306
 
307
+ // Validate URL — reject dangerous protocols (server-side + browser-side defense)
308
+ const trimmedPath = String(path).trim();
309
+ const normalizedUrl = trimmedPath.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
310
+ const isRelativePath = /^[/.#?]/.test(trimmedPath) || !trimmedPath.includes(':');
311
+ if (!isRelativePath && !/^https?:/.test(normalizedUrl)) {
312
+ return { error: `Blocked navigation to unsafe URL: "${path}"` };
313
+ }
314
+
296
315
  try {
297
316
  // Prefer What Router's navigate() if available
298
317
  const core = window.__WHAT_CORE__;
@@ -539,14 +558,26 @@ export async function handleExtendedCommand(command, args, devtools) {
539
558
  // page-map — Structured map of the entire visible page
540
559
  // -------------------------------------------------------------------------
541
560
  case 'page-map': {
561
+ // Each category gets its own budget so a page with many landmarks
562
+ // doesn't starve out headings or components. Previous behaviour shared
563
+ // one `count` across all sections — a page with >maxElements landmarks
564
+ // returned headings: [] and components: [], which agents read as
565
+ // "the page has no headings / no components".
542
566
  const maxElements = args?.maxElements || 200;
543
- let count = 0;
567
+ const perCategory = Math.max(50, Math.floor(maxElements / 2));
568
+
569
+ function rectOf(el) {
570
+ // Comment nodes / detached nodes have no rect; element walk handles that.
571
+ if (!el || el.nodeType !== 1) return { x: 0, y: 0, w: 0, h: 0 };
572
+ const r = el.getBoundingClientRect();
573
+ return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
574
+ }
544
575
 
545
576
  // Landmarks
546
577
  const landmarks = [];
547
578
  const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
548
579
  for (const el of landmarkEls) {
549
- if (count >= maxElements) break;
580
+ if (landmarks.length >= perCategory) break;
550
581
  const rect = el.getBoundingClientRect();
551
582
  if (rect.width === 0 && rect.height === 0) continue;
552
583
  landmarks.push({
@@ -554,16 +585,15 @@ export async function handleExtendedCommand(command, args, devtools) {
554
585
  role: el.getAttribute('role') || undefined,
555
586
  id: el.id || undefined,
556
587
  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) },
588
+ rect: rectOf(el),
558
589
  });
559
- count++;
560
590
  }
561
591
 
562
592
  // Interactive elements
563
593
  const interactives = [];
564
594
  const interactiveEls = document.querySelectorAll('button, a[href], input, select, textarea, [role=button], [role=link], [contenteditable]');
565
595
  for (const el of interactiveEls) {
566
- if (count >= maxElements) break;
596
+ if (interactives.length >= perCategory) break;
567
597
  const rect = el.getBoundingClientRect();
568
598
  if (rect.width === 0 && rect.height === 0) continue;
569
599
  const label = el.getAttribute('aria-label') || el.textContent?.trim().substring(0, 40) || el.getAttribute('placeholder') || el.getAttribute('name') || '';
@@ -572,39 +602,41 @@ export async function handleExtendedCommand(command, args, devtools) {
572
602
  type: el.getAttribute('type') || undefined,
573
603
  label: label || '(unlabeled)',
574
604
  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) },
605
+ rect: rectOf(el),
576
606
  });
577
- count++;
578
607
  }
579
608
 
580
- // Headings
609
+ // Headings — heading lists are nearly always small so we don't gate
610
+ // on visibility (some agents use display:none headings for SR-only nav).
581
611
  const headings = [];
582
612
  const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
583
613
  for (const el of headingEls) {
584
- if (count >= maxElements) break;
614
+ if (headings.length >= perCategory) break;
615
+ const text = (el.textContent || '').trim().substring(0, 80);
616
+ if (!text) continue; // skip truly empty headings
585
617
  headings.push({
586
- level: parseInt(el.tagName[1]),
587
- text: (el.textContent || '').trim().substring(0, 80),
618
+ level: parseInt(el.tagName[1], 10),
619
+ text,
620
+ id: el.id || undefined,
588
621
  });
589
- count++;
590
622
  }
591
623
 
592
- // WhatFW component boundaries
624
+ // WhatFW component boundaries. Comment-node boundaries have no
625
+ // dimensions, so a strict zero-rect check was filtering everything
626
+ // out. Walk the registry once and emit every component, using the
627
+ // resolved element's rect when available.
593
628
  const components = [];
594
629
  const registries = devtools?._registries;
595
630
  if (registries?.components) {
596
631
  for (const [id, entry] of registries.components) {
597
- if (count >= maxElements) break;
632
+ if (components.length >= perCategory) break;
598
633
  const compEl = getComponentElement(entry);
599
- if (!compEl) continue;
600
- const rect = compEl.getBoundingClientRect();
601
- if (rect.width === 0 && rect.height === 0) continue;
602
634
  components.push({
603
635
  id,
604
636
  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) },
637
+ rect: compEl ? rectOf(compEl) : { x: 0, y: 0, w: 0, h: 0 },
638
+ hasElement: !!compEl,
606
639
  });
607
- count++;
608
640
  }
609
641
  }
610
642
 
@@ -614,7 +646,7 @@ export async function handleExtendedCommand(command, args, devtools) {
614
646
  interactives,
615
647
  headings,
616
648
  components,
617
- totalElements: count,
649
+ totalElements: landmarks.length + interactives.length + headings.length + components.length,
618
650
  };
619
651
  }
620
652
 
package/src/client.js CHANGED
@@ -24,77 +24,112 @@ function logGrouped(badge, badgeStyle, title, data) {
24
24
  export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
25
25
  // Never connect in production
26
26
  if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') {
27
- return { disconnect() {}, isConnected: false, eventCount: 0 };
27
+ return { disconnect() {}, reconnect() {}, isConnected: false, eventCount: 0 };
28
28
  }
29
29
  try {
30
30
  if (import.meta?.env?.PROD) {
31
- return { disconnect() {}, isConnected: false, eventCount: 0 };
31
+ return { disconnect() {}, reconnect() {}, isConnected: false, eventCount: 0 };
32
32
  }
33
33
  } catch {}
34
34
 
35
35
  let ws = null;
36
36
  let connected = false;
37
- let reconnectTimer = null;
38
- let reconnectDelay = 1000;
39
- const MAX_RECONNECT_DELAY = 30000;
40
- const pendingResponses = new Map();
37
+ let stopped = false;
38
+ let probeTimer = null;
39
+ // Probe back-off: first retry after 10s, then ×3 each time, capped at 5min.
40
+ // With no bridge running this yields probes at ~0s / 10s / 40s / 130s / 430s…
41
+ // — at most 3-4 quiet fetch attempts in the first two minutes, then near-silence.
42
+ const PROBE_DELAY_INITIAL = 10000;
43
+ const PROBE_DELAY_RECONNECT = 2000; // bridge restarts are usually fast
44
+ const PROBE_BACKOFF_FACTOR = 3;
45
+ const PROBE_DELAY_MAX = 300000;
46
+ let probeDelay = PROBE_DELAY_INITIAL;
41
47
  let eventCount = 0;
42
- let hasLoggedDisconnect = false;
43
- let reconnectAttempts = 0;
48
+ let hasLoggedMissingBridge = false;
49
+ let hasShownBanner = false;
44
50
  let unsubscribeFn = null;
45
51
  let flushEventBatch = null; // Hoisted — set during subscription, called by set-signal
46
52
  let discoveredToken = token;
47
53
  let discoveredPort = port;
48
54
 
49
- // Startup banner
50
- console.log(
51
- '%c⚡ What DevTools MCP %c Client v0.2.0',
52
- 'background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;padding:4px 10px;border-radius:4px;font-weight:bold;font-size:13px',
53
- 'color:#a855f7;font-weight:bold'
54
- );
55
-
56
- // --- Token Auto-Discovery ---
57
- // If no token is provided, try to discover it from the bridge's HTTP endpoint.
58
- // The bridge serves GET http://localhost:{port+1}/__what_mcp_token
59
- async function discoverToken() {
60
- if (discoveredToken) return true; // Already have a token
61
-
62
- const discoveryPort = port + 1;
55
+ // --- Quiet bridge probe (also discovers the token) ---
56
+ // We NEVER open a WebSocket until this HTTP probe succeeds. A failed
57
+ // WebSocket connection always prints an unsuppressible red error in the
58
+ // browser console; a failed fetch wrapped in catch() prints at most a single
59
+ // muted "Failed to load resource" network line — the quietest probe the
60
+ // platform allows. The bridge serves GET http://localhost:{port+1}/__what_mcp_token
61
+ // (loopback-origin gated), so a 200 here means the bridge is up AND gives us
62
+ // the token + actual WS port in one round-trip.
63
+ //
64
+ // Trade-off: if the discovery HTTP port (port+1) is occupied by another
65
+ // process while the WS port is free, we won't connect even with an explicit
66
+ // token. That edge case is rarer and cheaper than spamming every fresh app
67
+ // (the default state: no bridge running) with red WS errors.
68
+ async function probeBridge() {
63
69
  try {
64
- const res = await fetch(`http://localhost:${discoveryPort}/__what_mcp_token`);
70
+ const res = await fetch(`http://localhost:${port + 1}/__what_mcp_token`, { cache: 'no-store' });
65
71
  if (res.ok) {
66
72
  const data = await res.json();
67
- discoveredToken = data.token;
73
+ if (data.token) discoveredToken = data.token;
68
74
  discoveredPort = data.wsPort || port;
69
- log('MCP', BADGE, `Token discovered automatically from bridge`);
70
75
  return true;
71
76
  }
72
77
  } catch {
73
- // Discovery endpoint not availablebridge may not be running yet
78
+ // Bridge not runningstay quiet; back-off handles retries.
74
79
  }
75
80
  return false;
76
81
  }
77
82
 
78
- log('MCP', BADGE, `Connecting to bridge on ws://localhost:${port}`);
83
+ async function tryConnect() {
84
+ if (stopped) return;
85
+ const bridgeUp = await probeBridge();
86
+ if (stopped) return;
87
+ if (!bridgeUp) {
88
+ if (!hasLoggedMissingBridge) {
89
+ hasLoggedMissingBridge = true;
90
+ // Single muted line — the ONLY console-API message a fresh app sees.
91
+ console.info(
92
+ '%c[what]%c devtools bridge not detected — start what-devtools-mcp to enable agent debugging (retrying quietly in background)',
93
+ DIM, DIM
94
+ );
95
+ }
96
+ scheduleProbe();
97
+ return;
98
+ }
99
+ openSocket();
100
+ }
101
+
102
+ function scheduleProbe() {
103
+ if (probeTimer || stopped) return;
104
+ probeTimer = setTimeout(() => {
105
+ probeTimer = null;
106
+ probeDelay = Math.min(probeDelay * PROBE_BACKOFF_FACTOR, PROBE_DELAY_MAX);
107
+ tryConnect();
108
+ }, probeDelay);
109
+ }
79
110
 
80
- function connect() {
81
- reconnectAttempts++;
111
+ function openSocket() {
82
112
  try {
83
113
  const tokenParam = discoveredToken ? `?token=${encodeURIComponent(discoveredToken)}` : '';
84
114
  ws = new WebSocket(`ws://localhost:${discoveredPort}${tokenParam}`);
85
115
  } catch {
86
- if (reconnectAttempts <= 1) {
87
- log('MCP', BADGE_WARN, 'Bridge not available — retrying silently in background');
88
- }
89
- scheduleReconnect();
116
+ scheduleProbe();
90
117
  return;
91
118
  }
92
119
 
93
120
  ws.onopen = () => {
94
121
  connected = true;
95
- reconnectDelay = 1000;
96
- hasLoggedDisconnect = false;
97
- reconnectAttempts = 0;
122
+ probeDelay = PROBE_DELAY_RECONNECT;
123
+ hasLoggedMissingBridge = false;
124
+ if (!hasShownBanner) {
125
+ hasShownBanner = true;
126
+ // Banner only once we KNOW the bridge exists — silent otherwise.
127
+ console.log(
128
+ '%c⚡ What DevTools MCP %c Client v0.2.0',
129
+ 'background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;padding:4px 10px;border-radius:4px;font-weight:bold;font-size:13px',
130
+ 'color:#a855f7;font-weight:bold'
131
+ );
132
+ }
98
133
  log('MCP', BADGE, '🟢 Connected to bridge — AI agent can now inspect this app');
99
134
 
100
135
  // Send initial snapshot
@@ -151,11 +186,12 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
151
186
  ws.onclose = () => {
152
187
  const wasConnected = connected;
153
188
  connected = false;
154
- if (wasConnected && !hasLoggedDisconnect) {
155
- hasLoggedDisconnect = true;
156
- log('MCP', BADGE_WARN, '🔴 Disconnected from bridgewill reconnect silently');
189
+ if (wasConnected) {
190
+ log('MCP', BADGE_WARN, '🔴 Disconnected from bridge — will reconnect quietly in background');
191
+ // Bridge restarts are usually quickretry fast, then back off.
192
+ probeDelay = PROBE_DELAY_RECONNECT;
157
193
  }
158
- scheduleReconnect();
194
+ scheduleProbe();
159
195
  };
160
196
 
161
197
  ws.onerror = () => {
@@ -207,7 +243,24 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
207
243
  break;
208
244
  }
209
245
  case 'set-signal': {
210
- const { signalId, value } = args || {};
246
+ const { signalId, value: rawValue } = args || {};
247
+ // Defense-in-depth: also coerce stringified-JSON here in case an
248
+ // older MCP server forwards the raw string. See tools.js coerceJsonValue.
249
+ let value = rawValue;
250
+ if (typeof rawValue === 'string') {
251
+ const t = rawValue.trim();
252
+ const first = t[0];
253
+ if (t.length > 0 && (
254
+ first === '{' || first === '[' ||
255
+ first === 't' || first === 'f' || first === 'n' ||
256
+ first === '-' || (first >= '0' && first <= '9')
257
+ )) {
258
+ try {
259
+ const parsed = JSON.parse(t);
260
+ if (typeof parsed !== 'string') value = parsed;
261
+ } catch {}
262
+ }
263
+ }
211
264
  const registries = devtools?._registries;
212
265
  if (registries?.signals) {
213
266
  const entry = registries.signals.get(signalId);
@@ -277,47 +330,47 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
277
330
  }
278
331
  }
279
332
 
280
- function scheduleReconnect() {
281
- if (reconnectTimer) return;
282
- // After 5 failures, go very slow (30s) to avoid console spam
283
- if (reconnectAttempts === 5 && !hasLoggedDisconnect) {
284
- log('MCP', BADGE_WARN, `Bridge not available — will retry every 30s. Start the MCP server to connect.`);
285
- hasLoggedDisconnect = true;
286
- }
287
- const delay = reconnectAttempts >= 5 ? MAX_RECONNECT_DELAY : reconnectDelay;
288
- reconnectTimer = setTimeout(async () => {
289
- reconnectTimer = null;
290
- reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
291
- // Try to discover token before each reconnect attempt
292
- // (bridge may have started since last attempt)
293
- await discoverToken();
294
- connect();
295
- }, delay);
296
- }
297
-
298
333
  function disconnect() {
334
+ stopped = true;
299
335
  if (unsubscribeFn) {
300
336
  unsubscribeFn();
301
337
  unsubscribeFn = null;
302
338
  }
303
- if (reconnectTimer) {
304
- clearTimeout(reconnectTimer);
305
- reconnectTimer = null;
339
+ if (probeTimer) {
340
+ clearTimeout(probeTimer);
341
+ probeTimer = null;
306
342
  }
307
343
  if (ws) {
308
344
  ws.onclose = null; // prevent reconnect
309
345
  ws.close();
310
346
  ws = null;
311
347
  }
348
+ if (connected) log('MCP', BADGE, 'Disconnected');
312
349
  connected = false;
313
- log('MCP', BADGE, 'Disconnected');
314
350
  }
315
351
 
316
- // Initial connecttry token discovery first, then connect
317
- discoverToken().then(() => connect());
352
+ // Manual retry escape hatch resets the back-off and probes immediately.
353
+ // Useful when the dev just started the bridge and doesn't want to wait
354
+ // out the back-off (or reload the page).
355
+ function reconnect() {
356
+ if (stopped || connected) return;
357
+ if (probeTimer) {
358
+ clearTimeout(probeTimer);
359
+ probeTimer = null;
360
+ }
361
+ probeDelay = PROBE_DELAY_INITIAL;
362
+ tryConnect();
363
+ }
364
+ if (typeof window !== 'undefined') {
365
+ window.__WHAT_MCP_RECONNECT__ = reconnect;
366
+ }
367
+
368
+ // Initial attempt — quiet probe first; WebSocket only if the bridge answers.
369
+ tryConnect();
318
370
 
319
371
  return {
320
372
  disconnect,
373
+ reconnect,
321
374
  get isConnected() { return connected; },
322
375
  get eventCount() { return eventCount; },
323
376
  };
package/src/index.js CHANGED
@@ -1033,7 +1033,7 @@ server.prompt(
1033
1033
  type: 'text',
1034
1034
  text: `# WhatFW MCP DevTools — Quick Reference
1035
1035
 
1036
- You have access to 28 MCP tools for inspecting and debugging a live What Framework app running in the browser.
1036
+ You have access to 29 MCP tools for inspecting and debugging a live What Framework app running in the browser.
1037
1037
 
1038
1038
  ## Connection Check
1039
1039
  Always start with: \`what_connection_status\` — confirms the browser is connected and shows signal/effect/component counts.