Render real, built ext-apps HTML panels against a mock MCP host, so you can iterate on a panel's UI — DOM, screenshots, console errors, mocked tool calls — without a real host or MCP server in the loop.
@overdraft-protocol/inspect-tools on npm · MIT licensed
edit panel -> build -> render_panel({ panelPath, fixture }) -> read DOM/errors/screenshot -> iterate
npx playwright install chromium # one-time: the default runner drives a real browser
claude mcp add inspect-tools -- npx -y --package=@overdraft-protocol/inspect-tools inspect-tools-mcpThen just ask Claude, e.g. "render dist/repos.html with this fixture and show me the screenshot" or "click the Save button and mock repos_set returning success, then check for console errors." See Using it with Claude for Claude Desktop setup, global registration, and running from a local checkout instead of npm.
render_panel({ panelPath | html | panelUrl | panel, fixture, capabilities?, mode?, viewport?, runner? })— load a panel, pushfixtureas the initial tool result, capture DOM + screenshot + console errors/uncaught exceptions + any tool calls the panel attempted with no mocked response configured.interact({ ...same as above, steps })— same, then replays a sequence of{ action: { type: "click"|"fill", selector }, respondWith?: { tool, result } | { tool, result }[] }steps.respondWithqueues the mockedtools/callresponse(s) the mock host returns when that action triggersapp.callServerTool(...)(e.g. a Save button). Pass an array when one gesture chains multiple calls (e.g. a mutate-then-reload pattern) — each entry is queued, in order, before the action fires.
render_panel is interact with no steps — same code path underneath. Provide
exactly one panel source:
| Source | What it does |
|---|---|
panelPath |
Read a built HTML file off disk. |
html |
The built HTML content, inline. |
panelUrl |
Fetch the built HTML from a URL, e.g. a running dev server (http://localhost:5173/repos.html). Needs no filesystem access — see Filesystem access. |
panel |
Look the name up in .inspect-tools.json (may resolve to either of the above) — see Project config. |
Instead of tracking build output paths by hand, add a .inspect-tools.json at
your project root:
{
"panels": {
"repos": { "path": "src/ui/dist/repos.html", "buildCommand": "npm run build:ui" },
"repos-dev": { "url": "http://localhost:5173/repos.html" }
}
}render_panel({ panel: "repos", fixture }) runs buildCommand (if present) and
resolves path relative to the config file's directory before rendering — so your
edit/build/render loop becomes one call instead of three. A url entry (like
repos-dev) is fetched instead of read off disk.
~/Documents, ~/Desktop, and ~/Downloads are TCC-protected: if the app that
spawned this server (e.g. Claude Desktop) was denied access to one of those folders,
panelPath/panel reads from there fail with EPERM — even for files you can read
yourself, since the OS grants access to the responsible app, not to this server. The
error message explains the cause and your options when this happens.
If you'd rather not grant that access, use panelUrl instead: point it at any local
dev server (vite, python -m http.server, whatever you already run). Loopback HTTP
isn't subject to the same restriction, so this needs no filesystem grant at all.
For use outside an MCP client (shell scripts, CI, quick manual checks):
npx -y --package=@overdraft-protocol/inspect-tools inspect-tools render \
--panel-path dist/repos.html --fixture @fixture.json --out screenshot.png
inspect-tools render --panel repos --cwd . --fixture '{"repos":[]}' --mode dom
inspect-tools render --help--fixture, --steps, --capabilities, and --tool-args accept either inline JSON
or @path/to/file.json. --out writes the screenshot to disk; --json prints the
full result (DOM, console messages, errors, unmapped tool calls, open-link attempts,
and the base64 screenshot) instead of the human-readable summary.
Capture a real tool result from an actual MCP server as a fixture, instead of hand-writing one:
inspect-tools capture-fixture \
--command node --arg path/to/server.js \
--tool get_repos --tool-args '{"owner":"anthropics"}' \
--out fixtures/repos.jsonSpawns the server over stdio (--command/--arg), calls --tool, and writes its
structuredContent to --out (or the full CallToolResult with --full) — ready to
feed straight into render_panel's fixture.
This is a stdio MCP server, so it works anywhere Claude reads an MCP server config: Claude Code (CLI, VS Code/JetBrains extensions) and Claude Desktop. It does not work as a Claude.ai web "custom connector" — those require a remote (HTTP/SSE) server, which this package doesn't provide.
Prerequisites: Node 20+, and (for the default Chromium runner) a one-time
npx playwright install chromium. Skip the browser install if you only ever use
runner: "jsdom".
claude mcp add inspect-tools -- npx -y --package=@overdraft-protocol/inspect-tools inspect-tools-mcpAdd -s user to register it globally instead of just the current project. Or write
.mcp.json by hand:
{
"mcpServers": {
"inspect-tools": {
"command": "npx",
"args": ["-y", "--package=@overdraft-protocol/inspect-tools", "inspect-tools-mcp"]
}
}
}Approve the server when Claude Code prompts you, then render_panel and interact
show up as tools. Run /mcp to confirm it's connected.
Easiest path: download the .mcpb bundle from the
latest release
and double-click it (or drag it into Settings → Extensions) — no JSON editing, no
restart required. A GitHub Actions workflow builds and attaches it to every v* tag.
Alternatively, add the same mcpServers block above to claude_desktop_config.json
(Settings → Developer → Edit Config, or
~/Library/Application Support/Claude/claude_desktop_config.json on macOS /
%APPDATA%\Claude\claude_desktop_config.json on Windows), then restart Claude
Desktop.
Developing the harness itself? Point at your local build instead:
{
"mcpServers": {
"inspect-tools": { "command": "node", "args": ["/absolute/path/to/inspect-tools/dist/mcp-server.js"] }
}
}(Run npm install && npm run build in the checkout first.)
Why the npx invocation needs --package=… and the -mcp suffix
The package ships two binaries: inspect-tools-mcp (the MCP server) and
inspect-tools (the CLI). Because the package name is scoped
(@overdraft-protocol/…), npx's default bin-name matching resolves the bare
package to inspect-tools — the CLI, not the server. Naming the server's bin
explicitly, with --package telling npx which package it lives in, is what actually
points Claude at the server. -y skips npx's install-confirmation prompt, needed
since Claude runs it non-interactively.
Panels built with @modelcontextprotocol/ext-apps's App class talk to their host
over postMessage: App.connect() sends ui/initialize, the host replies with
capabilities + context, the app sends ui/notifications/initialized, and the host
pushes the first ui/notifications/tool-result to trigger the panel's render. From
then on, app.callServerTool(...) proxies tools/call through the host.
This harness implements the host side of that protocol as a pure, self-contained
reducer (handleHostMessage, in src/mock-host.ts), plus two runners that share it:
- Chromium (default, always works) — loads the panel's real HTML into an iframe
inside a Playwright page and drives everything through real
postMessage/DOM/JS. Produces real screenshots. - jsdom (fast path, no screenshots) — runs the reducer directly in the same Node
process, no browser needed. Works for panels built as a classic (non-module)
script; see Known limitations for what it can't do. See
src/runner-jsdom.tsfor the (nontrivial) implementation notes if you're curious why it doesn't just use an iframe like the Chromium runner does.
ui/initialize,ui/notifications/initialized,ui/notifications/tool-result(push),tools/call,ui/open-link,ui/request-display-mode,resources/list,resources/templates/list, andresources/readare modeled with real, schema-valid behavior.sampling/createMessageisn't mocked and rejects cleanly (no capability flag to opt in yet).ui/download-file,ui/message, andui/update-model-contextstill get a generic empty-result acknowledgement — their result schemas make that a valid (if silent, unrecorded) success rather than a crash, but there's no way yet to assert a panel actually called them, the wayunmappedToolCalls/openLinkAttemptslet you assert on tool calls and open-link attempts.- The Chromium runner's panel iframe isn't sandboxed (no
sandboxattribute, no dedicated origin) — this simplifies same-origin DOM access but doesn't verify a panel's behavior under the CSP/sandbox restrictions a real host would apply. - jsdom cannot execute
<script type="module">(a jsdom limitation, not specific to this harness) and has no real layout engine, so it can't produce screenshots and auto-resize is a no-op there. Use the Chromium runner for module-script bundles or when you need a screenshot. panelUrlhas no timeout and no custom-header support — a hanging or auth-protected remote server will hang the render or fail with 401/403.
import { renderPanel, interact } from "@overdraft-protocol/inspect-tools";
const result = await renderPanel({
panelPath: "dist/repos.html",
fixture: { repos: [{ id: "1", name: "overdraft", starred: true }] },
});
// result.dom, result.screenshot (base64 PNG), result.errors, result.unmappedToolCallsgit clone https://github.com/overdraft-protocol/inspect-tools.git
cd inspect-tools
npm install
npm run selftestnpm run selftest builds the harness and a tiny real ext-apps panel (example/,
built with the actual App API), then runs it end to end: a real App.connect()
handshake, mocked tools/call responses flowing back through callServerTool,
capability gating, panelUrl/project-config resolution, and the CLI — against both
the Chromium and jsdom runners.