vscode-extension-devtool-mcp
by DrBoria
README.md
# vscode-extension-devtool-mcp
Give an AI agent **live eyes and hands inside your running app**.
`vscode-extension-devtool-mcp` is a small, standalone devtool bridge. Your app
(or VS Code extension) publishes itself as a **target**; a single MCP entry then
lets the agent inspect DOM, console logs, state stores, screenshots and
diagnostics — and click/type/scroll in the real UI.
- **Host-neutral** — VS Code webviews, Express, Nest, plain Node, any browser.
- **Store-agnostic** — MobX-State-Tree, Redux, Zustand, Apollo, or your own adapter.
- **Any number of targets** — 1 backend, 2 backends + 1 frontend, 3 backends + 4
frontends… all behind one MCP connection.
- **No host framework required** — the package depends on nothing from your app.
## Install
```sh
npm install vscode-extension-devtool-mcp
```
The package ships compiled JavaScript + type declarations (`dist/`); no
TypeScript build step is required in the consumer.
## Entry points
| Import | Runtime | What you get |
| --- | --- | --- |
| `vscode-extension-devtool-mcp` | Node | `createDevtool`, `DevtoolServer`, store adapters, diagnostics, hub + registry |
| `vscode-extension-devtool-mcp/webview` | Browser | `DevtoolProvider`, `connectWebFrontend`, DOM/store dispatch helpers |
| `vscode-extension-devtool-mcp/mcp-entry` | Node (stdio) | The multi-target MCP hub used by the agent |
| `vscode-extension-devtool-mcp/express` | Node | `createDevtool`, `devtoolMiddleware` |
| `vscode-extension-devtool-mcp/nest` | Node | `createDevtool`, `DevtoolNestMiddleware` |
| `vscode-extension-devtool-mcp/backend` | Node | Low-level backend surface (provider helpers, transports, hub) |
| `vscode-extension-devtool-mcp/hub` | Node | Registry + `DevtoolHub` on their own |
## Quick start — VS Code extension
### Backend (extension host)
```ts
import * as vscode from "vscode"
import { createDevtool, createMessageBridgeProvider, createMobxAdapter } from "vscode-extension-devtool-mcp"
const panel = vscode.window.createWebviewPanel(/* … */)
const pending = (handler: (message: Record<string, unknown>) => void) => {
const sub = panel.webview.onDidReceiveMessage(handler)
return () => sub.dispose()
}
const provider = createMessageBridgeProvider({
postMessageToWebview: (message) => void panel.webview.postMessage(message),
onWebviewMessage: pending,
listSurfaces: () => [{ id: "main", label: "Main panel" }],
getExtensionVersion: () => vscode.extensions.getExtension("publisher.my-ext")?.packageJSON.version,
executeCommand: (command, args) =>
vscode.commands.executeCommand(command, ...(Array.isArray(args) ? args : [])),
})
const devtool = createDevtool({
provider,
config: {
backend: { store: createMobxAdapter(rootStore) }, // optional
port: 0, // 0 = pick a free port automatically
statusPort: 0,
},
target: {
label: vscode.workspace.name ?? "my-extension",
kind: "vscode-extension",
},
})
const { url, targetId } = await devtool.start()
console.log(`[devtool] ${targetId} → ${url}`)
// on deactivate: await devtool.stop()
```
### Frontend (webview)
```tsx
import { DevtoolProvider } from "vscode-extension-devtool-mcp/webview"
const vscode = acquireVsCodeApi()
export const App = () => (
<DevtoolProvider
postMessage={(message) => vscode.postMessage(message)}
config={{ frontend: { framework: "mobx" } }}
rootStore={frontendRootStore}
surface="main">
<YourApp />
</DevtoolProvider>
)
```
`surface` is optional; use it when one extension has several panels so the agent
can address each one.
## Quick start — any web / Electron frontend
No VS Code required. Start a WebSocket frontend endpoint on the backend:
```ts
import { createDevtool, createWebFrontendServer, createMobxAdapter } from "vscode-extension-devtool-mcp"
const frontends = createWebFrontendServer({ port: 0 }) // 127.0.0.1 by default
const { url, port } = await frontends.start()
const devtool = createDevtool({
provider: frontends.createProvider(), // ready-made DOM/provider wiring
config: {
frontend: { framework: "mobx" }, // the store lives in the browser
port: 0,
statusPort: 0,
},
frontendBridge: frontends,
target: { label: "my-spa" },
})
await devtool.start()
console.log(`frontend endpoint: ${url}`) // ws://127.0.0.1:PORT/frontend
```
And in the browser bundle:
```ts
import { connectWebFrontend } from "vscode-extension-devtool-mcp/webview"
connectWebFrontend({
url: `ws://127.0.0.1:${port}/frontend`,
surface: "main",
label: "Main tab",
rootStore, // MST root: enables store tools
// or: store: createReduxAdapter(store) for Redux/Zustand/Apollo
getActionBuffer: () => [], // optional: enables action tools
})
```
Open several tabs and give each a distinct `surface` — all of them attach to the
same target and are individually addressable.
## Quick start — Express / Nest
```ts
import { createDevtool, devtoolMiddleware } from "vscode-extension-devtool-mcp/express"
const devtool = createDevtool({
provider: frontends.createProvider(),
config: { frontend: { framework: "mobx" }, port: 0, statusPort: 0 },
frontendBridge: frontends,
target: { label: "api", kind: "express" },
})
await devtool.start()
app.use(devtoolMiddleware(devtool))
```
Nest exposes the same factory plus `DevtoolNestMiddleware` from
`vscode-extension-devtool-mcp/nest`.
## Configuration matrix
The three common setups. `config` is the single switch that decides which tool
groups exist; declaring a side without a store still gives you its non-store
tools (DOM, console, diagnostics, commands).
### 1. Backend only, with an MST store
No webview and no frontend transport is required — omit `postMessageToWebview`
and `onWebviewMessage`:
```ts
import * as vscode from "vscode"
import { createDevtool, createMessageBridgeProvider, createMobxAdapter } from "vscode-extension-devtool-mcp"
const devtool = createDevtool({
provider: createMessageBridgeProvider({
getExtensionVersion: () => vscode.extensions.getExtension("publisher.my-ext")?.packageJSON.version,
executeCommand: (command, args) =>
vscode.commands.executeCommand(command, ...(Array.isArray(args) ? args : [])),
}),
config: {
backend: { store: createMobxAdapter(backendRootStore) },
port: 0, // ephemeral
statusPort: 0,
},
target: { label: vscode.workspace.name ?? "my-extension", kind: "vscode-extension" },
})
await devtool.start()
// Frontend tools (click_element, …) now fail fast with
// "No frontend is configured for this target" instead of timing out.
```
If you do not even need command execution, you can omit the provider entirely:
```ts
const devtool = createDevtool({
config: { backend: { store: createMobxAdapter(backendRootStore) }, port: 0, statusPort: 0 },
target: { label: "my-extension" },
})
```
### 2. Backend + frontend, both with MST stores
Backend (extension host):
```ts
import * as vscode from "vscode"
import { createDevtool, createMessageBridgeProvider, createMobxAdapter } from "vscode-extension-devtool-mcp"
const webviews = new Map<string, vscode.Webview>() // surface id -> panel webview
const allWebviews = () => [...webviews.values()]
const provider = createMessageBridgeProvider({
postMessageToWebview: (message, surface) => {
const webview = surface ? webviews.get(surface) : allWebviews()[0]
void webview?.postMessage(message)
},
onWebviewMessage: (handler) => {
const subscriptions = allWebviews().map((webview) => webview.onDidReceiveMessage(handler))
return () => subscriptions.forEach((subscription) => subscription.dispose())
},
listSurfaces: () => [...webviews.keys()].map((id) => ({ id, label: id })),
getExtensionVersion: () => vscode.extensions.getExtension("publisher.my-ext")?.packageJSON.version,
executeCommand: (command, args) =>
vscode.commands.executeCommand(command, ...(Array.isArray(args) ? args : [])),
})
const devtool = createDevtool({
provider,
config: {
backend: { store: createMobxAdapter(backendRootStore) },
// The frontend store lives in the webview, so the backend only declares
// its framework. The webview supplies the actual store below.
frontend: { framework: "mobx" },
port: 0,
statusPort: 0,
},
target: { label: vscode.workspace.name ?? "my-extension", kind: "vscode-extension" },
})
await devtool.start()
```
Frontend (webview):
```tsx
import { DevtoolProvider } from "vscode-extension-devtool-mcp/webview"
const vscode = acquireVsCodeApi()
export const App = () => (
<DevtoolProvider
postMessage={(message) => vscode.postMessage(message)}
config={{ frontend: { framework: "mobx" } }}
rootStore={frontendRootStore}
surface="main">
<YourApp />
</DevtoolProvider>
)
```
### 3. Backend + frontend without MST (or without any store)
Declare the sides but do not pass a store. You still get DOM, console,
diagnostics and command tools; store tools are not registered at all.
```ts
const devtool = createDevtool({
provider,
config: {
backend: {}, // backend console/logs/commands, no state tools
frontend: {}, // DOM/console tools, no state tools
port: 0,
statusPort: 0,
},
target: { label: "my-app" },
})
```
If the frontend uses Redux/Zustand/Apollo, declare the framework on the backend
and pass the matching adapter on the frontend:
```ts
// backend
config: { backend: {}, frontend: { framework: "redux" }, port: 0, statusPort: 0 }
// webview
import { createReduxAdapter } from "vscode-extension-devtool-mcp"
<DevtoolProvider
postMessage={(message) => vscode.postMessage(message)}
config={{ frontend: { store: createReduxAdapter(reduxStore) } }}
surface="main" />
```
For a plain web/Electron frontend the same choice is made at the connector:
```ts
connectWebFrontend({ url, surface: "main", rootStore }) // MST
connectWebFrontend({ url, surface: "main", store: reduxAdapter }) // any framework
connectWebFrontend({ url, surface: "main" }) // no state tools
```
## Any number of backends and frontends
Every `devtool.start()` publishes a record to a shared registry directory
(default: `<os tmp>/vscode-extension-devtool-mcp/targets`). The MCP hub reads
that directory, so targets are discovered automatically — one port per target,
no configuration conflicts. If two processes ask for the same port, the second
one transparently binds a free port.
```
┌──────────────────────────┐
│ MCP client (agent) │
└────────────┬─────────────┘
│ stdio
┌────────────▼─────────────┐
│ mcp-entry (DevtoolHub) │
└───┬──────────┬───────────┘
ws://…:A │ │ ws://…:B
┌─────────▼──┐ ┌───▼────────┐
│ backend 1 │ │ backend 2 │
│ frontend 1 │ │ frontend 2 │
│ frontend 2 │ │ frontend 3 │
└────────────┘ └────────────┘
```
The hub exposes:
- every target tool, plus an optional **`target`** argument;
- `list_targets` — every running target, its label, ports and capabilities;
- `select_target` — set the default target for subsequent calls;
- `refresh_targets` — re-scan after starting/stopping a target;
- `get_target_info` — identity, ports and liveness of one target;
- `list_frontend_surfaces` / `surface` argument — pick one of several frontends.
```jsonc
// tools/list excerpt
{ "name": "get_store_state", "inputSchema": { "properties": { "target": { "type": "string" } } } }
```
When only one target is running, `target` can be omitted. When several are
running without a selection, the hub returns a clear error listing them instead
of guessing.
### Record attribution (2, 3, N backends/frontends)
A **target** is one backend process (one extension host, one server). A target
can expose **any number of frontend surfaces**. So:
- 3 backends → 3 targets (3 processes or 3 `DevtoolServer` instances);
- 4 frontends → 4 surfaces, distributed across targets however you like.
Nothing is mixed together. Every proxied result is attributed:
```jsonc
// get_store_state { target: "app-a", env: "frontend", surface: "surface-app-a-side" }
{
"stores": { "alphaSideFrontend": 20 },
"target": { "id": "app-a", "label": "App A", "kind": "node", "backend": true, "frontend": true },
"surface": "surface-app-a-side"
}
```
- JSON results get `target` (id, label, kind, capabilities) and, for frontend
tools, `surface` (the surface that answered, also mirrored as `connector`).
- Tool results also carry the same data in MCP `structuredContent`.
- Non-JSON text gets a `[target: label (id)]` prefix.
- `list_targets` lists all backends; `list_frontend_surfaces` lists all
frontends of one target.
- When several targets run and no `target` is given, the hub returns an error
listing them instead of guessing.
### Ports / registry
| Environment variable | Default | Purpose |
| --- | --- | --- |
| `DEVTOOL_REGISTRY_DIR` | OS temp dir | Shared directory of target records |
| `DEVTOOL_REGISTRY` | `1` | Set `0` to stop publishing a target |
| `DEVTOOL_TARGET_ID` | generated | Stable target id |
| `DEVTOOL_TARGET_LABEL` | workspace/pid | Label shown to the agent |
| `DEVTOOL_TARGET_KIND` | `other` | `vscode-extension`, `express`, `nest`, `node` |
| `DEVTOOL_TARGETS` | — | Extra comma-separated `ws://…` URLs for the hub |
| `DEVTOOL_TARGET` | — | Default target id/label for the hub |
The hub and the targets must see the same `DEVTOOL_REGISTRY_DIR`. If a target
runs in a container or a different machine, pass its URL with `DEVTOOL_TARGETS`
or `--target ws://host:port/ws`.
## MCP client configuration
Point the agent at the hub entry (no build step, no fixed port):
```json
{
"mcpServers": {
"devtool": {
"command": "node",
"args": ["./node_modules/vscode-extension-devtool-mcp/dist/mcp/transport/stdio.js"],
"env": { "DEVTOOL_REGISTRY_DIR": "/tmp/vscode-extension-devtool-mcp/targets" }
}
}
}
```
or, for a single explicit target:
```sh
node ./node_modules/vscode-extension-devtool-mcp/dist/mcp/transport/stdio.js --target ws://127.0.0.1:60060/ws
```
## Store adapters
| Framework | Adapter |
| --- | --- |
| MobX-State-Tree | `createMobxAdapter(rootStore)` |
| Redux | `createReduxAdapter(store)` |
| Zustand | `createZustandAdapter(useStore)` |
| Apollo | `createApolloAdapter(client)` |
| Anything else | `createPlainAdapter({ framework, getState })` |
Passing an adapter enables snapshot, search, action and history tools for that
side. Without an adapter those tools are simply not registered — the agent never
sees them. Custom adapters implement the small `IStoreAdapter` interface.
## What the agent can do
- **Backend** — `get_logs`, `get_diagnostics_snapshot`, `clear_diagnostics`,
`get_console`, `search_console`, `execute_vscode_command`, `get_extension_info`
- **Frontend** — `find_element`, `click_element`, `type_text`, `scroll_element`,
`select_option`, `drag_element`, `drag_from_to`, `run_command`,
`get_screenshot`, `get_active_page`, `list_frontend_surfaces`
- **State** — `get_store_state`, `search_state`, `get_store_actions`,
`filter_actions`, `search_actions`, `count_actions`, `get_store_actions_log`
- **Hub** — `list_targets`, `select_target`, `refresh_targets`, `get_target_info`,
`get_renderer_console`
See [docs/architecture.md](./docs/architecture.md) for the internal layout.
## Development
```sh
npm install
npm run check-types
npm run lint
npm test # unit tests + real-process integration tests (builds first)
npm run test:unit # fast vitest suite in src/
npm run test:integration # tests/: real stdio hub, real sockets, real jsdom DOM
npm run build # emits dist/ (JS + .d.ts)
node examples/e2e-multi-target.mjs # 2 backends + 2 frontends over one hub
```
## License
Apache-2.0 — see [LICENSE](./LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing