what-devtools-mcp 0.6.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/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # what-devtools-mcp
2
+
3
+ AI-powered debugging for What Framework apps. Lets Claude Code, Cursor, Windsurf, and other AI agents inspect your app's live state — signals, effects, components, errors — in real time.
4
+
5
+ > **What is MCP?** [Model Context Protocol](https://modelcontextprotocol.io) is an open standard that lets AI coding assistants call tools in external systems. This package exposes your app's runtime state as MCP tools that your AI assistant can call while helping you debug.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ Browser (What App + devtools) ──WebSocket:9229──▶ Node.js Process
11
+ ├── WS Bridge (state + events)
12
+ └── MCP Server (stdio) ◀── Claude Code / Cursor
13
+ ```
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install --save-dev what-devtools what-devtools-mcp
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ ### Step 1: Add the Vite plugin
24
+
25
+ ```js
26
+ // vite.config.js
27
+ import what from 'what-compiler/vite';
28
+ import whatDevToolsMCP from 'what-devtools-mcp/vite-plugin';
29
+
30
+ export default {
31
+ plugins: [what(), whatDevToolsMCP()],
32
+ };
33
+ ```
34
+
35
+ The plugin auto-injects the devtools client in dev mode only (`apply: 'serve'`). It never runs during `vite build`.
36
+
37
+ ### Step 2: Configure your AI tool
38
+
39
+ **Claude Code** — add to `.claude/mcp.json` in your project root:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "what-devtools": {
45
+ "command": "npx",
46
+ "args": ["what-devtools-mcp"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ **Cursor** — add to `.cursor/mcp.json` in your project root:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "what-devtools": {
58
+ "command": "npx",
59
+ "args": ["what-devtools-mcp"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Other MCP clients** — any client that supports stdio transport can connect with:
66
+
67
+ ```
68
+ command: npx what-devtools-mcp
69
+ ```
70
+
71
+ ### Step 3: Start your app
72
+
73
+ ```bash
74
+ npm run dev
75
+ # In another terminal (or your AI tool starts it automatically):
76
+ # The MCP server runs via your AI tool's MCP config — no manual start needed.
77
+ ```
78
+
79
+ You should see in the browser console:
80
+ ```
81
+ ⚡ What DevTools MCP Client v0.1.0
82
+ MCP Connecting to bridge on ws://localhost:9229
83
+ MCP 🟢 Connected to bridge — AI agent can now inspect this app
84
+ MCP Sent initial snapshot — 5 signals, 3 effects, 2 components
85
+ ```
86
+
87
+ ## What Can Your AI Do Now?
88
+
89
+ Here's a real debugging session with Claude Code:
90
+
91
+ ```
92
+ You: "The counter isn't updating when I click the button"
93
+
94
+ Claude: Let me check your app's reactive state.
95
+ [calls what_signals { filter: "count" }]
96
+
97
+ I can see signal 'count' exists with value 0. Let me watch
98
+ what happens when you click.
99
+ [calls what_watch { duration: 5000, filter: "signal:updated" }]
100
+
101
+ No signal updates detected in 5 seconds. The click handler
102
+ isn't writing to the signal. Let me check the component.
103
+ [calls what_dom_inspect { componentId: 2 }]
104
+
105
+ Found it — the button's onclick calls increment() but that
106
+ function uses count.peek() instead of count.set(). peek()
107
+ reads without triggering reactivity. Here's the fix...
108
+ ```
109
+
110
+ The AI sees your app's internals in real time — no console.log, no breakpoints, no guesswork.
111
+
112
+ ## Available Tools
113
+
114
+ ### Read Tools (inspect state)
115
+
116
+ | Tool | Description |
117
+ |------|-------------|
118
+ | `what_connection_status` | Check if browser is connected, get counts |
119
+ | `what_signals` | List signals with values. Filter by name regex or ID |
120
+ | `what_effects` | List effects with deps, run counts, timing. Filter by name or dep signal |
121
+ | `what_components` | List mounted components. Filter by name |
122
+ | `what_snapshot` | Full state snapshot (signals + effects + components + errors) |
123
+ | `what_errors` | Runtime errors with effect context and stack traces |
124
+ | `what_cache` | SWR/useQuery cache entries. Filter by key |
125
+ | `what_component_tree` | Hierarchical component tree with parent-child relationships |
126
+ | `what_dependency_graph` | Reactive dependency graph — which signals feed which effects |
127
+ | `what_dom_inspect` | See a component's rendered DOM output |
128
+ | `what_route` | Current route info (path, params, query, matched pattern) |
129
+
130
+ ### Write Tools (modify state)
131
+
132
+ | Tool | Description |
133
+ |------|-------------|
134
+ | `what_set_signal` | Set a signal's value and see what changes |
135
+ | `what_invalidate_cache` | Force-refresh a cache key |
136
+ | `what_eval` | Execute arbitrary JS in the browser context |
137
+ | `what_navigate` | Navigate to a different route |
138
+
139
+ ### Observe Tools (watch changes)
140
+
141
+ | Tool | Description |
142
+ |------|-------------|
143
+ | `what_watch` | Collect reactive events over a time window |
144
+ | `what_diff_snapshot` | Save a baseline, take action, diff the changes |
145
+
146
+ ### Diagnostic Tools
147
+
148
+ | Tool | Description |
149
+ |------|-------------|
150
+ | `what_diagnose` | Multi-check diagnostic — errors, performance, reactivity in one call |
151
+
152
+ ## Signal Debug Names
153
+
154
+ For best debugging output, add names to your signals:
155
+
156
+ ```js
157
+ // Without name: shows as "signal_1" in devtools
158
+ const count = signal(0);
159
+
160
+ // With name: shows as "count" in devtools
161
+ const count = signal(0, 'count');
162
+ ```
163
+
164
+ A compiler transform to auto-inject names from variable declarations is planned.
165
+
166
+ ## Manual Setup (without Vite plugin)
167
+
168
+ ```js
169
+ import * as core from 'what-core';
170
+ import { installDevTools } from 'what-devtools';
171
+ import { connectDevToolsMCP } from 'what-devtools-mcp/client';
172
+
173
+ installDevTools(core);
174
+ connectDevToolsMCP({ port: 9229 });
175
+ ```
176
+
177
+ ## Configuration
178
+
179
+ | Option | Default | Description |
180
+ |--------|---------|-------------|
181
+ | `WHAT_MCP_PORT` env var | `9229` | WebSocket bridge port |
182
+ | Vite plugin `{ port }` | `9229` | Same, via plugin option |
183
+
184
+ Port 9229 matches Node.js `--inspect` convention. If you're also debugging Node.js, set a different port:
185
+
186
+ ```js
187
+ whatDevToolsMCP({ port: 9230 })
188
+ ```
189
+
190
+ ```json
191
+ {
192
+ "mcpServers": {
193
+ "what-devtools": {
194
+ "command": "npx",
195
+ "args": ["what-devtools-mcp"],
196
+ "env": { "WHAT_MCP_PORT": "9230" }
197
+ }
198
+ }
199
+ }
200
+ ```
201
+
202
+ ## Troubleshooting
203
+
204
+ **"No browser connected"**
205
+ Your app isn't running or the Vite plugin isn't configured. Start your dev server and check the browser console for the MCP connection banner.
206
+
207
+ **"No snapshot available"**
208
+ The browser connected but hasn't sent state yet. Refresh the page.
209
+
210
+ **WebSocket connection refused**
211
+ Port 9229 may be in use. Check with `lsof -i :9229` and either kill the process or use a different port.
212
+
213
+ **Multiple browser tabs**
214
+ Only the most recently connected tab's state is tracked. Close extra tabs or use the one you're debugging.
215
+
216
+ **Empty signal names (signal_1, signal_2)**
217
+ Add debug names: `signal(0, 'count')`. See "Signal Debug Names" above.
218
+
219
+ ## WebMCP (Future)
220
+
221
+ What Framework is designed to support WebMCP — running the MCP server directly in the browser, eliminating the WebSocket bridge entirely. The tool handlers are being built transport-agnostic so they can run in either Node.js or browser context.
222
+
223
+ See `DESIGN.md` for the full WebMCP architecture plan.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "what-devtools-mcp",
3
+ "version": "0.6.0",
4
+ "description": "MCP server bridging AI agents to live What Framework app state via WebSocket",
5
+ "type": "module",
6
+ "bin": {
7
+ "what-devtools-mcp": "./src/index.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./client": "./src/client.js",
12
+ "./vite-plugin": "./src/vite-plugin.js"
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.0.0",
19
+ "ws": "^8.0.0",
20
+ "zod": "^3.25.0"
21
+ },
22
+ "peerDependencies": {
23
+ "what-devtools": ">=0.6.0"
24
+ },
25
+ "keywords": [
26
+ "what-framework",
27
+ "devtools",
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "ai-debugging"
31
+ ],
32
+ "license": "MIT"
33
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Node.js WebSocket server + state bridge.
3
+ * Receives state snapshots and events from the browser client,
4
+ * provides query API for the MCP tools.
5
+ */
6
+
7
+ import { WebSocketServer } from 'ws';
8
+
9
+ const MAX_EVENT_LOG = 1000;
10
+ const MAX_ERROR_LOG = 100;
11
+
12
+ export function createBridge({ port = 9229 } = {}) {
13
+ let latestSnapshot = null;
14
+ const eventLog = [];
15
+ const errorLog = [];
16
+ let browserSocket = null;
17
+ let correlationCounter = 0;
18
+ const pendingCommands = new Map();
19
+
20
+ // Snapshot dedup cache (100ms)
21
+ let cachedSnapshot = null;
22
+ let cacheTime = 0;
23
+ const SNAPSHOT_CACHE_MS = 100;
24
+
25
+ // Baseline snapshot for diff tool
26
+ let baselineSnapshot = null;
27
+
28
+ const wss = new WebSocketServer({ port });
29
+
30
+ wss.on('connection', (ws) => {
31
+ browserSocket = ws;
32
+
33
+ ws.on('message', (raw) => {
34
+ let msg;
35
+ try { msg = JSON.parse(raw); } catch { return; }
36
+
37
+ switch (msg.type) {
38
+ case 'snapshot':
39
+ latestSnapshot = msg.data;
40
+ break;
41
+ case 'event':
42
+ eventLog.push({ event: msg.event, data: msg.data, timestamp: Date.now() });
43
+ if (eventLog.length > MAX_EVENT_LOG) eventLog.shift();
44
+ // Track errors separately
45
+ if (msg.event === 'error:captured') {
46
+ errorLog.push({ ...msg.data, timestamp: Date.now() });
47
+ if (errorLog.length > MAX_ERROR_LOG) errorLog.shift();
48
+ }
49
+ break;
50
+ case 'events':
51
+ for (const item of msg.batch || []) {
52
+ eventLog.push({ event: item.event, data: item.data, timestamp: Date.now() });
53
+ if (eventLog.length > MAX_EVENT_LOG) eventLog.shift();
54
+ if (item.event === 'error:captured') {
55
+ errorLog.push({ ...item.data, timestamp: Date.now() });
56
+ if (errorLog.length > MAX_ERROR_LOG) errorLog.shift();
57
+ }
58
+ }
59
+ break;
60
+ case 'response': {
61
+ const pending = pendingCommands.get(msg.correlationId);
62
+ if (pending) {
63
+ pendingCommands.delete(msg.correlationId);
64
+ pending.resolve(msg.data);
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ });
70
+
71
+ ws.on('close', () => {
72
+ if (browserSocket === ws) browserSocket = null;
73
+ // Reject all pending commands
74
+ for (const [id, pending] of pendingCommands) {
75
+ pending.reject(new Error('Browser disconnected'));
76
+ pendingCommands.delete(id);
77
+ }
78
+ });
79
+ });
80
+
81
+ function isConnected() {
82
+ return browserSocket !== null && browserSocket.readyState === 1; // WebSocket.OPEN
83
+ }
84
+
85
+ function sendCommand(command, args = {}, timeout = 5000) {
86
+ return new Promise((resolve, reject) => {
87
+ if (!isConnected()) {
88
+ return reject(new Error('No browser connected'));
89
+ }
90
+ const correlationId = `cmd_${++correlationCounter}`;
91
+ const timer = setTimeout(() => {
92
+ pendingCommands.delete(correlationId);
93
+ reject(new Error(`Command '${command}' timed out after ${timeout}ms`));
94
+ }, timeout);
95
+
96
+ pendingCommands.set(correlationId, {
97
+ resolve: (data) => { clearTimeout(timer); resolve(data); },
98
+ reject: (err) => { clearTimeout(timer); reject(err); },
99
+ });
100
+
101
+ browserSocket.send(JSON.stringify({ command, correlationId, args }));
102
+ });
103
+ }
104
+
105
+ async function refreshSnapshot() {
106
+ const data = await sendCommand('get-snapshot');
107
+ latestSnapshot = data;
108
+ return data;
109
+ }
110
+
111
+ async function getOrRefreshSnapshot() {
112
+ const now = Date.now();
113
+ if (cachedSnapshot && now - cacheTime < SNAPSHOT_CACHE_MS) return cachedSnapshot;
114
+ try {
115
+ cachedSnapshot = await refreshSnapshot();
116
+ cacheTime = now;
117
+ return cachedSnapshot;
118
+ } catch {
119
+ return latestSnapshot;
120
+ }
121
+ }
122
+
123
+ async function getCacheSnapshot() {
124
+ return sendCommand('get-cache');
125
+ }
126
+
127
+ function getSnapshot() {
128
+ return latestSnapshot;
129
+ }
130
+
131
+ function saveBaseline() {
132
+ baselineSnapshot = latestSnapshot ? JSON.parse(JSON.stringify(latestSnapshot)) : null;
133
+ return !!baselineSnapshot;
134
+ }
135
+
136
+ function getBaseline() {
137
+ return baselineSnapshot;
138
+ }
139
+
140
+ function getEvents(since) {
141
+ if (since) return eventLog.filter(e => e.timestamp > since);
142
+ return eventLog.slice();
143
+ }
144
+
145
+ function getErrors(since) {
146
+ if (since) return errorLog.filter(e => e.timestamp > since);
147
+ return errorLog.slice();
148
+ }
149
+
150
+ function close() {
151
+ for (const [id, pending] of pendingCommands) {
152
+ pending.reject(new Error('Bridge closing'));
153
+ pendingCommands.delete(id);
154
+ }
155
+ wss.close();
156
+ }
157
+
158
+ return {
159
+ getSnapshot,
160
+ getOrRefreshSnapshot,
161
+ getEvents,
162
+ getErrors,
163
+ isConnected,
164
+ sendCommand,
165
+ refreshSnapshot,
166
+ getCacheSnapshot,
167
+ saveBaseline,
168
+ getBaseline,
169
+ close,
170
+ };
171
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Extended command handlers for the browser client.
3
+ * Handles: eval, dom-inspect, get-route, navigate
4
+ *
5
+ * Usage in client.js:
6
+ * import { handleExtendedCommand } from './client-commands.js';
7
+ *
8
+ * // Inside handleCommand(), before the default case:
9
+ * const extResult = handleExtendedCommand(command, args, devtools);
10
+ * if (extResult !== null) { result = extResult; break; }
11
+ */
12
+
13
+ /**
14
+ * Handle extended commands sent from the MCP server via the bridge.
15
+ *
16
+ * @param {string} command - The command name
17
+ * @param {object} args - Command arguments
18
+ * @param {object|null} devtools - window.__WHAT_DEVTOOLS__ reference
19
+ * @returns {object|null} Result object, or null if command not handled
20
+ */
21
+ export function handleExtendedCommand(command, args, devtools) {
22
+ switch (command) {
23
+
24
+ // -------------------------------------------------------------------------
25
+ // eval — Execute arbitrary JS in the browser context
26
+ // -------------------------------------------------------------------------
27
+ case 'eval': {
28
+ const start = performance.now();
29
+ try {
30
+ // Use Function constructor to execute in global scope
31
+ // eslint-disable-next-line no-new-func
32
+ const fn = new Function(args.code);
33
+ const raw = fn();
34
+ const elapsed = performance.now() - start;
35
+ return {
36
+ result: devtools?.safeSerialize ? devtools.safeSerialize(raw) : raw,
37
+ type: typeof raw,
38
+ executionTime: Math.round(elapsed * 100) / 100,
39
+ };
40
+ } catch (e) {
41
+ return {
42
+ error: e.message,
43
+ stack: e.stack,
44
+ };
45
+ }
46
+ }
47
+
48
+ // -------------------------------------------------------------------------
49
+ // dom-inspect — Serialize a component's rendered DOM
50
+ // -------------------------------------------------------------------------
51
+ case 'dom-inspect': {
52
+ const { componentId, depth = 3 } = args || {};
53
+ const registries = devtools?._registries;
54
+
55
+ if (!registries?.components) {
56
+ return { error: 'DevTools registries not available' };
57
+ }
58
+
59
+ const entry = registries.components.get(componentId);
60
+ if (!entry) {
61
+ return { error: `Component ${componentId} not found` };
62
+ }
63
+
64
+ const el = entry.element;
65
+ if (!el) {
66
+ return { error: `Component "${entry.name}" (id: ${componentId}) has no DOM element` };
67
+ }
68
+
69
+ /**
70
+ * Recursively serialize a DOM node into a plain object.
71
+ * Respects the max depth limit to avoid huge payloads.
72
+ */
73
+ function serializeDOM(node, currentDepth) {
74
+ if (currentDepth > depth) {
75
+ return { tag: '...', text: '(truncated)' };
76
+ }
77
+
78
+ // Text node
79
+ if (node.nodeType === 3) {
80
+ const text = node.textContent?.trim() || '';
81
+ if (!text) return null; // skip empty text nodes
82
+ return { text };
83
+ }
84
+
85
+ // Skip non-element, non-text nodes (comments, etc.)
86
+ if (node.nodeType !== 1) return null;
87
+
88
+ const result = {
89
+ tag: node.tagName.toLowerCase(),
90
+ };
91
+
92
+ // Include common identifying attributes
93
+ if (node.id) result.id = node.id;
94
+ if (node.className && typeof node.className === 'string') {
95
+ result.class = node.className;
96
+ }
97
+
98
+ // Include data attributes (often useful for debugging)
99
+ const dataAttrs = {};
100
+ for (const attr of node.attributes) {
101
+ if (attr.name.startsWith('data-')) {
102
+ dataAttrs[attr.name] = attr.value;
103
+ }
104
+ }
105
+ if (Object.keys(dataAttrs).length > 0) {
106
+ result.dataAttributes = dataAttrs;
107
+ }
108
+
109
+ // Recurse into children
110
+ const children = [];
111
+ for (const child of node.childNodes) {
112
+ const serialized = serializeDOM(child, currentDepth + 1);
113
+ if (serialized && (serialized.tag || serialized.text)) {
114
+ children.push(serialized);
115
+ }
116
+ }
117
+ if (children.length) result.children = children;
118
+
119
+ return result;
120
+ }
121
+
122
+ const structure = serializeDOM(el, 0);
123
+ // Cap HTML to 5000 chars to prevent huge payloads
124
+ const html = el.innerHTML?.substring(0, 5000) || '';
125
+
126
+ return {
127
+ componentName: entry.name,
128
+ componentId,
129
+ html,
130
+ structure,
131
+ };
132
+ }
133
+
134
+ // -------------------------------------------------------------------------
135
+ // get-route — Return current route information
136
+ // -------------------------------------------------------------------------
137
+ case 'get-route': {
138
+ const loc = typeof window !== 'undefined' ? window.location : {};
139
+ const result = {
140
+ path: loc.pathname || '/',
141
+ query: Object.fromEntries(new URLSearchParams(loc.search || '')),
142
+ hash: loc.hash || '',
143
+ fullUrl: loc.href || '',
144
+ };
145
+
146
+ // Try to get What Router state if available
147
+ try {
148
+ const core = window.__WHAT_CORE__;
149
+ if (core?.routerState) {
150
+ const state = typeof core.routerState === 'function'
151
+ ? core.routerState()
152
+ : core.routerState;
153
+ if (state) {
154
+ result.params = state.params || {};
155
+ result.matchedRoute = state.pattern || state.route || null;
156
+ }
157
+ }
158
+ } catch {
159
+ // Router state not available — that's fine, we have window.location
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ // -------------------------------------------------------------------------
166
+ // navigate — Programmatically change the route
167
+ // -------------------------------------------------------------------------
168
+ case 'navigate': {
169
+ const { path, replace } = args || {};
170
+
171
+ if (!path) {
172
+ return { error: 'No path provided' };
173
+ }
174
+
175
+ try {
176
+ // Prefer What Router's navigate() if available
177
+ const core = window.__WHAT_CORE__;
178
+ if (core?.navigate) {
179
+ core.navigate(path, { replace: !!replace });
180
+ } else if (replace) {
181
+ history.replaceState(null, '', path);
182
+ window.dispatchEvent(new PopStateEvent('popstate'));
183
+ } else {
184
+ history.pushState(null, '', path);
185
+ window.dispatchEvent(new PopStateEvent('popstate'));
186
+ }
187
+
188
+ return {
189
+ navigatedTo: path,
190
+ currentPath: window.location.pathname,
191
+ method: replace ? 'replaceState' : 'pushState',
192
+ usedWhatRouter: !!(core?.navigate),
193
+ success: true,
194
+ };
195
+ } catch (e) {
196
+ return { error: e.message };
197
+ }
198
+ }
199
+
200
+ // -------------------------------------------------------------------------
201
+ // Not handled — return null so caller falls through
202
+ // -------------------------------------------------------------------------
203
+ default:
204
+ return null;
205
+ }
206
+ }