Skip to main content
Glama
vscode-extension-devtool-mcp

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).