what-devtools-mcp 0.10.0 → 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.10.0",
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": {
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 = [];
@@ -60,6 +60,30 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
60
60
  }
61
61
  }});
62
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
+
63
87
  // --- Token Discovery ---
64
88
  // Two mechanisms so the browser client can find the token automatically:
65
89
  //
@@ -138,9 +138,16 @@ export async function handleExtendedCommand(command, args, devtools) {
138
138
  const segments = code.split('.');
139
139
  const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
140
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
+ ]);
141
148
  const isSafeRead = segments.length >= 2 &&
142
149
  SAFE_GLOBALS.has(segments[0]) &&
143
- segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s));
150
+ segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s) && !SENSITIVE_PATHS.has(s));
144
151
 
145
152
  if (!evalEnabled && !isSafeRead) {
146
153
  return {
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 = () => {
@@ -294,47 +330,47 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
294
330
  }
295
331
  }
296
332
 
297
- function scheduleReconnect() {
298
- if (reconnectTimer) return;
299
- // After 5 failures, go very slow (30s) to avoid console spam
300
- if (reconnectAttempts === 5 && !hasLoggedDisconnect) {
301
- log('MCP', BADGE_WARN, `Bridge not available — will retry every 30s. Start the MCP server to connect.`);
302
- hasLoggedDisconnect = true;
303
- }
304
- const delay = reconnectAttempts >= 5 ? MAX_RECONNECT_DELAY : reconnectDelay;
305
- reconnectTimer = setTimeout(async () => {
306
- reconnectTimer = null;
307
- reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
308
- // Try to discover token before each reconnect attempt
309
- // (bridge may have started since last attempt)
310
- await discoverToken();
311
- connect();
312
- }, delay);
313
- }
314
-
315
333
  function disconnect() {
334
+ stopped = true;
316
335
  if (unsubscribeFn) {
317
336
  unsubscribeFn();
318
337
  unsubscribeFn = null;
319
338
  }
320
- if (reconnectTimer) {
321
- clearTimeout(reconnectTimer);
322
- reconnectTimer = null;
339
+ if (probeTimer) {
340
+ clearTimeout(probeTimer);
341
+ probeTimer = null;
323
342
  }
324
343
  if (ws) {
325
344
  ws.onclose = null; // prevent reconnect
326
345
  ws.close();
327
346
  ws = null;
328
347
  }
348
+ if (connected) log('MCP', BADGE, 'Disconnected');
329
349
  connected = false;
330
- log('MCP', BADGE, 'Disconnected');
331
350
  }
332
351
 
333
- // Initial connecttry token discovery first, then connect
334
- 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();
335
370
 
336
371
  return {
337
372
  disconnect,
373
+ reconnect,
338
374
  get isConnected() { return connected; },
339
375
  get eventCount() { return eventCount; },
340
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.
@@ -434,9 +434,16 @@ export function registerExtendedTools(server, bridge) {
434
434
  const segments = trimmed.split('.');
435
435
  const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
436
436
  const PROTO_DENYLIST = new Set(['constructor', 'prototype', '__proto__']);
437
+ // Sensitive property paths: reads that exfiltrate secrets/state must
438
+ // require the explicit unsafe flag (document.cookie, window.localStorage,
439
+ // navigator.credentials...). Mirror of SENSITIVE_PATHS in client-commands.js.
440
+ const SENSITIVE_PATHS = new Set([
441
+ 'cookie', 'localStorage', 'sessionStorage', 'indexedDB', 'credentials',
442
+ 'geolocation', 'clipboard', 'serviceWorker', 'caches', 'opener',
443
+ ]);
437
444
  const isSafeRead = segments.length >= 2 &&
438
445
  SAFE_GLOBALS.has(segments[0]) &&
439
- segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s));
446
+ segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s) && !SENSITIVE_PATHS.has(s));
440
447
 
441
448
  if (!unsafeEvalEnabled && !isSafeRead) {
442
449
  return errorResponse(