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 +13 -3
- package/src/bridge.js +57 -5
- package/src/client-commands.js +60 -28
- package/src/client.js +118 -65
- package/src/index.js +1 -1
- package/src/tools-agent.js +290 -5
- package/src/tools-extended.js +81 -19
- package/src/tools.js +164 -8
- package/src/vite-plugin.js +45 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-devtools-mcp",
|
|
3
|
-
"version": "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": {
|
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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]
|
|
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;
|
package/src/client-commands.js
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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 (
|
|
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:
|
|
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 (
|
|
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
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
|
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 = () => {
|
|
@@ -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 (
|
|
304
|
-
clearTimeout(
|
|
305
|
-
|
|
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
|
-
//
|
|
317
|
-
|
|
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
|
|
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.
|