what-devtools-mcp 0.10.0 → 0.11.1
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/README.md +12 -0
- package/package.json +1 -1
- package/src/bridge.js +25 -1
- package/src/client-commands.js +8 -1
- package/src/client.js +144 -65
- package/src/index.js +36 -2
- package/src/tools-extended.js +8 -1
- package/src/vite-plugin.js +36 -1
package/README.md
CHANGED
|
@@ -174,6 +174,18 @@ installDevTools(core);
|
|
|
174
174
|
connectDevToolsMCP({ port: 9229 });
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
+
Without the Vite plugin the client starts **dormant** — it does not poll for a
|
|
178
|
+
bridge, so apps with no bridge running produce zero console/network noise.
|
|
179
|
+
To activate it, do one of:
|
|
180
|
+
|
|
181
|
+
- pass an explicit `token` (e.g. from `WHAT_MCP_TOKEN`): `connectDevToolsMCP({ token })`
|
|
182
|
+
- set `window.__WHAT_DEVTOOLS_DEBUG__ = true` before calling it
|
|
183
|
+
- call `window.__WHAT_MCP_RECONNECT__()` from the browser console after starting the bridge
|
|
184
|
+
|
|
185
|
+
The Vite plugin doesn't need any of this: it serves a same-origin
|
|
186
|
+
`/__what_mcp_discovery` endpoint and the client polls that quietly, so the
|
|
187
|
+
bridge is discovered automatically whenever it's running.
|
|
188
|
+
|
|
177
189
|
## Configuration
|
|
178
190
|
|
|
179
191
|
| Option | Default | Description |
|
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
|
@@ -21,80 +21,143 @@ function logGrouped(badge, badgeStyle, title, data) {
|
|
|
21
21
|
console.groupEnd();
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
24
|
+
export function connectDevToolsMCP({ port = 9229, token = '', discoveryUrl = '' } = {}) {
|
|
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.
|
|
61
|
+
//
|
|
62
|
+
// Two probe modes:
|
|
63
|
+
//
|
|
64
|
+
// 1. Same-origin discovery (preferred — `discoveryUrl`, set by the Vite
|
|
65
|
+
// plugin): the dev server proxies the bridge probe Node-side, so a
|
|
66
|
+
// missing bridge produces ZERO console output — the dev server itself is
|
|
67
|
+
// up, so there is no network-layer ERR_CONNECTION_REFUSED line either.
|
|
68
|
+
//
|
|
69
|
+
// 2. Direct probe of GET http://localhost:{port+1}/__what_mcp_token
|
|
70
|
+
// (loopback-origin gated; a 200 means the bridge is up AND returns the
|
|
71
|
+
// token + actual WS port in one round-trip). Even wrapped in catch(),
|
|
72
|
+
// the browser's network layer logs net::ERR_CONNECTION_REFUSED for every
|
|
73
|
+
// failed attempt — unsuppressible from JS — so this mode only runs when
|
|
74
|
+
// a bridge is actually expected (explicit token, or the
|
|
75
|
+
// window.__WHAT_DEVTOOLS_DEBUG__ flag). See `pollEnabled` below.
|
|
76
|
+
//
|
|
77
|
+
// Trade-off (direct mode): if the discovery HTTP port (port+1) is occupied
|
|
78
|
+
// by another process while the WS port is free, we won't connect even with
|
|
79
|
+
// an explicit token. That edge case is rarer and cheaper than spamming
|
|
80
|
+
// every fresh app (the default state: no bridge running) with red WS errors.
|
|
81
|
+
async function probeBridge() {
|
|
82
|
+
if (discoveryUrl) {
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(discoveryUrl, { cache: 'no-store' });
|
|
85
|
+
if (res.ok) {
|
|
86
|
+
const data = await res.json();
|
|
87
|
+
if (!data.bridge) return false;
|
|
88
|
+
if (data.token) discoveredToken = data.token;
|
|
89
|
+
discoveredPort = data.wsPort || port;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Dev server unreachable (page about to die) — stay quiet.
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
63
97
|
try {
|
|
64
|
-
const res = await fetch(`http://localhost:${
|
|
98
|
+
const res = await fetch(`http://localhost:${port + 1}/__what_mcp_token`, { cache: 'no-store' });
|
|
65
99
|
if (res.ok) {
|
|
66
100
|
const data = await res.json();
|
|
67
|
-
discoveredToken = data.token;
|
|
101
|
+
if (data.token) discoveredToken = data.token;
|
|
68
102
|
discoveredPort = data.wsPort || port;
|
|
69
|
-
log('MCP', BADGE, `Token discovered automatically from bridge`);
|
|
70
103
|
return true;
|
|
71
104
|
}
|
|
72
105
|
} catch {
|
|
73
|
-
//
|
|
106
|
+
// Bridge not running — stay quiet; back-off handles retries.
|
|
74
107
|
}
|
|
75
108
|
return false;
|
|
76
109
|
}
|
|
77
110
|
|
|
78
|
-
|
|
111
|
+
async function tryConnect() {
|
|
112
|
+
if (stopped) return;
|
|
113
|
+
const bridgeUp = await probeBridge();
|
|
114
|
+
if (stopped) return;
|
|
115
|
+
if (!bridgeUp) {
|
|
116
|
+
if (!hasLoggedMissingBridge) {
|
|
117
|
+
hasLoggedMissingBridge = true;
|
|
118
|
+
// Single muted line — the ONLY console-API message a fresh app sees.
|
|
119
|
+
console.info(
|
|
120
|
+
'%c[what]%c devtools bridge not detected — start what-devtools-mcp to enable agent debugging (retrying quietly in background)',
|
|
121
|
+
DIM, DIM
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
scheduleProbe();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
openSocket();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scheduleProbe() {
|
|
131
|
+
if (probeTimer || stopped) return;
|
|
132
|
+
probeTimer = setTimeout(() => {
|
|
133
|
+
probeTimer = null;
|
|
134
|
+
probeDelay = Math.min(probeDelay * PROBE_BACKOFF_FACTOR, PROBE_DELAY_MAX);
|
|
135
|
+
tryConnect();
|
|
136
|
+
}, probeDelay);
|
|
137
|
+
}
|
|
79
138
|
|
|
80
|
-
function
|
|
81
|
-
reconnectAttempts++;
|
|
139
|
+
function openSocket() {
|
|
82
140
|
try {
|
|
83
141
|
const tokenParam = discoveredToken ? `?token=${encodeURIComponent(discoveredToken)}` : '';
|
|
84
142
|
ws = new WebSocket(`ws://localhost:${discoveredPort}${tokenParam}`);
|
|
85
143
|
} catch {
|
|
86
|
-
|
|
87
|
-
log('MCP', BADGE_WARN, 'Bridge not available — retrying silently in background');
|
|
88
|
-
}
|
|
89
|
-
scheduleReconnect();
|
|
144
|
+
scheduleProbe();
|
|
90
145
|
return;
|
|
91
146
|
}
|
|
92
147
|
|
|
93
148
|
ws.onopen = () => {
|
|
94
149
|
connected = true;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
150
|
+
probeDelay = PROBE_DELAY_RECONNECT;
|
|
151
|
+
hasLoggedMissingBridge = false;
|
|
152
|
+
if (!hasShownBanner) {
|
|
153
|
+
hasShownBanner = true;
|
|
154
|
+
// Banner only once we KNOW the bridge exists — silent otherwise.
|
|
155
|
+
console.log(
|
|
156
|
+
'%c⚡ What DevTools MCP %c Client v0.2.0',
|
|
157
|
+
'background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;padding:4px 10px;border-radius:4px;font-weight:bold;font-size:13px',
|
|
158
|
+
'color:#a855f7;font-weight:bold'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
98
161
|
log('MCP', BADGE, '🟢 Connected to bridge — AI agent can now inspect this app');
|
|
99
162
|
|
|
100
163
|
// Send initial snapshot
|
|
@@ -151,11 +214,12 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
|
151
214
|
ws.onclose = () => {
|
|
152
215
|
const wasConnected = connected;
|
|
153
216
|
connected = false;
|
|
154
|
-
if (wasConnected
|
|
155
|
-
|
|
156
|
-
|
|
217
|
+
if (wasConnected) {
|
|
218
|
+
log('MCP', BADGE_WARN, '🔴 Disconnected from bridge — will reconnect quietly in background');
|
|
219
|
+
// Bridge restarts are usually quick — retry fast, then back off.
|
|
220
|
+
probeDelay = PROBE_DELAY_RECONNECT;
|
|
157
221
|
}
|
|
158
|
-
|
|
222
|
+
scheduleProbe();
|
|
159
223
|
};
|
|
160
224
|
|
|
161
225
|
ws.onerror = () => {
|
|
@@ -294,47 +358,62 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
|
294
358
|
}
|
|
295
359
|
}
|
|
296
360
|
|
|
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
361
|
function disconnect() {
|
|
362
|
+
stopped = true;
|
|
316
363
|
if (unsubscribeFn) {
|
|
317
364
|
unsubscribeFn();
|
|
318
365
|
unsubscribeFn = null;
|
|
319
366
|
}
|
|
320
|
-
if (
|
|
321
|
-
clearTimeout(
|
|
322
|
-
|
|
367
|
+
if (probeTimer) {
|
|
368
|
+
clearTimeout(probeTimer);
|
|
369
|
+
probeTimer = null;
|
|
323
370
|
}
|
|
324
371
|
if (ws) {
|
|
325
372
|
ws.onclose = null; // prevent reconnect
|
|
326
373
|
ws.close();
|
|
327
374
|
ws = null;
|
|
328
375
|
}
|
|
376
|
+
if (connected) log('MCP', BADGE, 'Disconnected');
|
|
329
377
|
connected = false;
|
|
330
|
-
log('MCP', BADGE, 'Disconnected');
|
|
331
378
|
}
|
|
332
379
|
|
|
333
|
-
//
|
|
334
|
-
|
|
380
|
+
// Manual retry escape hatch — resets the back-off and probes immediately.
|
|
381
|
+
// Useful when the dev just started the bridge and doesn't want to wait
|
|
382
|
+
// out the back-off (or reload the page). Also the opt-in for clients that
|
|
383
|
+
// start dormant (see pollEnabled below).
|
|
384
|
+
function reconnect() {
|
|
385
|
+
if (stopped || connected) return;
|
|
386
|
+
if (probeTimer) {
|
|
387
|
+
clearTimeout(probeTimer);
|
|
388
|
+
probeTimer = null;
|
|
389
|
+
}
|
|
390
|
+
probeDelay = PROBE_DELAY_INITIAL;
|
|
391
|
+
tryConnect();
|
|
392
|
+
}
|
|
393
|
+
if (typeof window !== 'undefined') {
|
|
394
|
+
window.__WHAT_MCP_RECONNECT__ = reconnect;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Direct cross-origin polling logs net::ERR_CONNECTION_REFUSED from the
|
|
398
|
+
// browser's network layer on every attempt when no bridge runs — JS cannot
|
|
399
|
+
// suppress it, and a fresh scaffold (no bridge) saw that on every load.
|
|
400
|
+
// So without a same-origin discoveryUrl, only poll when a bridge is
|
|
401
|
+
// actually expected: an explicit token was provided, or the developer set
|
|
402
|
+
// window.__WHAT_DEVTOOLS_DEBUG__ = true. Otherwise stay dormant — zero
|
|
403
|
+
// network traffic, zero console output — until reconnect() opts in
|
|
404
|
+
// (window.__WHAT_MCP_RECONNECT__() from the console works too).
|
|
405
|
+
const pollEnabled = Boolean(
|
|
406
|
+
discoveryUrl
|
|
407
|
+
|| token
|
|
408
|
+
|| (typeof window !== 'undefined' && window.__WHAT_DEVTOOLS_DEBUG__)
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// Initial attempt — quiet probe first; WebSocket only if the bridge answers.
|
|
412
|
+
if (pollEnabled) tryConnect();
|
|
335
413
|
|
|
336
414
|
return {
|
|
337
415
|
disconnect,
|
|
416
|
+
reconnect,
|
|
338
417
|
get isConnected() { return connected; },
|
|
339
418
|
get eventCount() { return eventCount; },
|
|
340
419
|
};
|
package/src/index.js
CHANGED
|
@@ -4,18 +4,52 @@
|
|
|
4
4
|
* Creates WS bridge, registers tools + resources, connects MCP stdio transport.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
7
8
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
10
|
import { createBridge } from './bridge.js';
|
|
10
11
|
import { registerTools } from './tools.js';
|
|
11
12
|
|
|
13
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
14
|
+
|
|
15
|
+
// --help / --version must print and exit BEFORE any server starts — this bin
|
|
16
|
+
// otherwise opens a WebSocket bridge + discovery HTTP server and blocks on
|
|
17
|
+
// the MCP stdio transport, which made `what-devtools-mcp --help` hang.
|
|
18
|
+
const cliArgs = process.argv.slice(2);
|
|
19
|
+
if (cliArgs.includes('--help') || cliArgs.includes('-h')) {
|
|
20
|
+
console.log(`what-devtools-mcp ${pkg.version} — MCP server bridging AI agents to live What Framework app state
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
what-devtools-mcp [flags]
|
|
24
|
+
|
|
25
|
+
Runs an MCP server on stdio (add it to your MCP client config) plus a local
|
|
26
|
+
WebSocket bridge the browser devtools client connects to.
|
|
27
|
+
|
|
28
|
+
Flags:
|
|
29
|
+
-h, --help Show this help and exit
|
|
30
|
+
-v, --version Print the version and exit
|
|
31
|
+
--unsafe-eval Enable the what_eval tool (arbitrary JS in the browser — dev only)
|
|
32
|
+
|
|
33
|
+
Environment:
|
|
34
|
+
WHAT_MCP_PORT WebSocket bridge port (default: 9229; token discovery on port+1)
|
|
35
|
+
WHAT_MCP_TOKEN Pre-shared auth token (default: random per run)
|
|
36
|
+
WHAT_UNSAFE_EVAL Set to 1/true to enable what_eval (same as --unsafe-eval)
|
|
37
|
+
|
|
38
|
+
Docs: https://whatfw.com`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (cliArgs.includes('--version') || cliArgs.includes('-v')) {
|
|
42
|
+
console.log(pkg.version);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
12
46
|
const port = parseInt(process.env.WHAT_MCP_PORT || '9229', 10);
|
|
13
47
|
|
|
14
48
|
const bridge = createBridge({ port });
|
|
15
49
|
|
|
16
50
|
const server = new McpServer({
|
|
17
51
|
name: 'what-devtools-mcp',
|
|
18
|
-
version:
|
|
52
|
+
version: pkg.version,
|
|
19
53
|
});
|
|
20
54
|
|
|
21
55
|
registerTools(server, bridge);
|
|
@@ -1033,7 +1067,7 @@ server.prompt(
|
|
|
1033
1067
|
type: 'text',
|
|
1034
1068
|
text: `# WhatFW MCP DevTools — Quick Reference
|
|
1035
1069
|
|
|
1036
|
-
You have access to
|
|
1070
|
+
You have access to 29 MCP tools for inspecting and debugging a live What Framework app running in the browser.
|
|
1037
1071
|
|
|
1038
1072
|
## Connection Check
|
|
1039
1073
|
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(
|
package/src/vite-plugin.js
CHANGED
|
@@ -41,11 +41,46 @@ const RESOLVED_BOOTSTRAP_ID = '\0' + VIRTUAL_BOOTSTRAP_ID;
|
|
|
41
41
|
// Vite encodes `\0` as `__x00__` in `/@id/` URLs — stable since Vite 2 (2021).
|
|
42
42
|
const BROWSER_BOOTSTRAP_URL = '/@id/__x00__' + VIRTUAL_BOOTSTRAP_ID;
|
|
43
43
|
|
|
44
|
+
// Same-origin discovery endpoint served by the dev server (see configureServer).
|
|
45
|
+
// The browser polls THIS instead of the bridge's cross-origin port directly:
|
|
46
|
+
// a failed cross-origin fetch logs net::ERR_CONNECTION_REFUSED in the console
|
|
47
|
+
// on every fresh scaffold (no bridge running) — unsuppressible from JS. The
|
|
48
|
+
// dev server itself is always up while the page is open, so polling it never
|
|
49
|
+
// produces console noise; the bridge probe happens Node-side instead.
|
|
50
|
+
export const DISCOVERY_PATH = '/__what_mcp_discovery';
|
|
51
|
+
|
|
44
52
|
export default function whatDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
45
53
|
return {
|
|
46
54
|
name: 'what-devtools-mcp',
|
|
47
55
|
apply: 'serve',
|
|
48
56
|
|
|
57
|
+
// Node-side bridge probe, exposed same-origin to the browser client.
|
|
58
|
+
// Responds { bridge: false } when no bridge is running (quietly), or
|
|
59
|
+
// { bridge: true, token, wsPort } when it is — one round-trip discovery.
|
|
60
|
+
configureServer(server) {
|
|
61
|
+
server.middlewares.use(DISCOVERY_PATH, async (req, res) => {
|
|
62
|
+
res.setHeader('Content-Type', 'application/json');
|
|
63
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
64
|
+
try {
|
|
65
|
+
const probe = await fetch(`http://127.0.0.1:${port + 1}/__what_mcp_token`, {
|
|
66
|
+
signal: AbortSignal.timeout(1000),
|
|
67
|
+
});
|
|
68
|
+
if (probe.ok) {
|
|
69
|
+
const data = await probe.json();
|
|
70
|
+
res.end(JSON.stringify({
|
|
71
|
+
bridge: true,
|
|
72
|
+
token: data.token || '',
|
|
73
|
+
wsPort: data.wsPort || port,
|
|
74
|
+
}));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Bridge not running — the normal state for a fresh scaffold.
|
|
79
|
+
}
|
|
80
|
+
res.end(JSON.stringify({ bridge: false }));
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
49
84
|
// Resolve the virtual module so Vite knows we own it.
|
|
50
85
|
resolveId(id) {
|
|
51
86
|
if (id === VIRTUAL_BOOTSTRAP_ID || id === RESOLVED_BOOTSTRAP_ID) {
|
|
@@ -66,7 +101,7 @@ export default function whatDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
|
66
101
|
`import { installDevTools } from 'what-devtools';`,
|
|
67
102
|
`import { connectDevToolsMCP } from 'what-devtools-mcp/client';`,
|
|
68
103
|
`installDevTools(core);`,
|
|
69
|
-
`connectDevToolsMCP({ port: ${port}, token: ${JSON.stringify(tokenValue)} });`,
|
|
104
|
+
`connectDevToolsMCP({ port: ${port}, token: ${JSON.stringify(tokenValue)}, discoveryUrl: ${JSON.stringify(DISCOVERY_PATH)} });`,
|
|
70
105
|
].join('\n');
|
|
71
106
|
},
|
|
72
107
|
|