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 +1 -1
- package/src/bridge.js +25 -1
- package/src/client-commands.js +8 -1
- package/src/client.js +100 -64
- package/src/index.js +1 -1
- package/src/tools-extended.js +8 -1
package/package.json
CHANGED
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
|
//
|
package/src/client-commands.js
CHANGED
|
@@ -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
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
let
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:${
|
|
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
|
-
//
|
|
78
|
+
// Bridge not running — stay quiet; back-off handles retries.
|
|
74
79
|
}
|
|
75
80
|
return false;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
if (wasConnected) {
|
|
190
|
+
log('MCP', BADGE_WARN, '🔴 Disconnected from bridge — will reconnect quietly in background');
|
|
191
|
+
// Bridge restarts are usually quick — retry fast, then back off.
|
|
192
|
+
probeDelay = PROBE_DELAY_RECONNECT;
|
|
157
193
|
}
|
|
158
|
-
|
|
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 (
|
|
321
|
-
clearTimeout(
|
|
322
|
-
|
|
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
|
-
//
|
|
334
|
-
|
|
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
|
|
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.
|
package/src/tools-extended.js
CHANGED
|
@@ -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(
|