opencode-webmcp
opencode-webmcp
MCP bridge connecting OpenCode to Chromium's WebMCP API.
Exposes WebMCP tools registered in a Chromium page as MCP tools consumable by any MCP client (OpenCode, Claude Code, etc.).
How It Works
OpenCode (MCP client)
↕ MCP stdio protocol
webmcp-bridge (MCP server)
↕ Puppeteer + CDP
Chromium (WebMCP enabled)
↕ monkey-patched document.modelContext
Web page with registered WebMCP toolsThe bridge launches Chromium with --enable-features=WebMCPTesting, navigates to a target page, discovers tools via the WebMCP API, and makes them available to MCP clients.
Monkey-patch: Chrome 150+ has a bug where page.webmcp.invokeTool() returns {} instead of the actual tool result. The bridge works around this by intercepting registerTool to capture execute functions and replacing executeTool to call the captured functions directly.
Prerequisites
Node.js 18+
Chromium/Chrome with WebMCP support (Chrome 150+)
An MCP client (OpenCode, Claude Code, etc.)
Setup
git clone <repo-url> webmcp-bridge
cd webmcp-bridge
npm installUsage
Standalone
CHROME_PATH=/usr/bin/chromium \
WEBMCP_TARGET_URL="https://www.google.com" \
WEBMCP_HEADLESS=false \
node server.jsWith a config file
node server.js --config ./webmcp.yamlIf --config is omitted, the bridge looks for ./webmcp.yaml, ./webmcp.yml, or ./webmcp.json in the current working directory before falling back to environment variables.
Layered precedence (highest wins): CLI args → config file → environment variables → built-in defaults.
For example, node server.js --no-headless overrides headless: true in a config file, which overrides WEBMCP_HEADLESS=true in the environment.
Supported keys (camelCase): chromePath, targetUrl, headless, historyMax, logHistory.
Example webmcp.yaml:
chromePath: /usr/bin/chromium
targetUrl: https://www.google.com
headless: false
historyMax: 500
logHistory: falseOr webmcp.json:
{
"chromePath": "/usr/bin/chromium",
"targetUrl": "https://www.google.com",
"headless": false
}OpenCode (opencode.json)
{
"mcp": {
"webmcp-bridge": {
"command": ["node", "/path/to/webmcp-bridge/server.js"],
"enabled": true,
"type": "local",
"environment": {
"CHROME_PATH": "/usr/bin/chromium",
"WEBMCP_TARGET_URL": "about:blank",
"WEBMCP_HEADLESS": "false"
}
}
}
}Quick Start (OpenCode)
After adding the config above, restart OpenCode. The bridge tools are now available — just ask:
"Navigate to a WebMCP-enabled page and tell me what tools are available"
OpenCode calls webmcp_navigate → bridge opens the page → webmcp_status to show discovered tools. From there you can invoke any page tool, take screenshots, evaluate JS, or manage tabs conversationally.
For a full 8-step walkthrough (status → navigate → discover → invoke → screenshot → evaluate → history), see examples/GETTING_STARTED.md.
Environment Variables
Variable | Default | Description |
|
| Path to Chromium/Chrome executable |
|
| Initial page to navigate to |
|
| Run headless ( |
|
| Maximum number of tool invocations to keep in the in-memory history ring buffer |
|
| When |
|
| Scan the page DOM for declarative WebMCP tools ( |
Bridge-Native Tools
These tools are always available, regardless of what the page exposes:
Tool | Description |
| Navigate Chrome to a URL and refresh discovered WebMCP tools |
| Report connection status, current URL, and available tools |
| Execute arbitrary JavaScript in the page context |
| Call any WebMCP tool by name with arguments |
| Register sample tools ( |
| Capture a screenshot of the current page (PNG/JPEG, viewport/full-page/clip) and return it as an MCP image |
| Return recent tool invocations from the in-memory history buffer (most recent first; optional |
| Empty the in-memory history buffer |
| Open a new tab, optionally navigating to a URL; returns the new tabId |
| Make a specific tab the active tab |
| List all open tabs with |
| Close a tab by tabId (cannot close the last one) |
webmcp_screenshot example
Default (viewport PNG):
{ "name": "webmcp_screenshot", "arguments": {} }Full-page JPEG at quality 75:
{ "name": "webmcp_screenshot", "arguments": { "format": "jpeg", "quality": 75, "fullPage": true } }Region clip (200×100 at top-left):
{ "name": "webmcp_screenshot", "arguments": { "clip": { "x": 0, "y": 0, "width": 200, "height": 100 } } }Multi-Tab Sessions
The bridge can manage multiple Puppeteer tabs sharing a single browser context. Each tab has a UUID tabId; one tab is active at a time. Tools without tabId act on the active tab; pass tabId to target a specific tab without switching.
{ "name": "webmcp_open_tab", "arguments": { "url": "https://example.com" } }
// → { "tabId": "a1b2c3d4-…", "url": "https://example.com" }
{ "name": "webmcp_list_tabs", "arguments": {} }
// → { "count": 2, "tabs": [{ "tabId": "…", "url": "…", "title": "…", "isActive": true }, …] }
{ "name": "webmcp_switch_tab", "arguments": { "tabId": "a1b2c3d4-…" } }
{ "name": "webmcp_invoke_tool", "arguments": { "name": "search", "args": { "q": "weather" }, "tabId": "a1b2c3d4-…" } }
{ "name": "webmcp_close_tab", "arguments": { "tabId": "a1b2c3d4-…" } }Closing the last remaining tab returns an error. When the active tab is closed (with other tabs remaining), the next tab in the map becomes active.
Architecture
server.js— single-file MCP server using@modelcontextprotocol/sdkPuppeteer (
puppeteer-core) — drives Chromium via Chrome DevTools ProtocolMonkey-patch —
page.evaluateOnNewDocumentinjects a script that interceptsdocument.modelContext.registerToolandexecuteToolto capture actualexecutefunctionsDeclarative API scanner — also scans the DOM for
<form toolname="…">and synthesizes MCP tools from[toolname-target]attributes. Wired through a 100ms-debounced MutationObserver that flips a dirty flag whenever[toolname]attributes change. Imperative tools take precedence on name collisions.
Tool Annotations
MCP tool annotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are surfaced to MCP clients. Defaults for bridge-native tools:
Tool | readOnly | destructive | openWorld |
| false | false | true |
| true | false | false |
| false | true | true |
| (page-determined) | (page-determined) | (page-determined) |
| true | false | false |
| false | false | false |
| true | false | false |
| false | true | false |
| false | false | false |
Page-discovered tools forward their annotations to MCP clients as-is. The bridge never overrides page-supplied annotations. If a page calls registerTool("delete_account", { annotations: { destructiveHint: true } }), that annotation flows through to tools/list. Annotation changes on the page trigger notifications/tools/list_changed.
Clients that ignore the annotations field see no behavior change.
Declarative WebMCP API
Chrome's WebMCP API has two surfaces. The bridge handles both:
Imperative — pages call
document.modelContext.registerTool({ name, inputSchema, execute }).Declarative — pages annotate HTML with
toolname/toolname-targetattributes:<form toolname="search"> <input name="q" toolname-target="search.query" required> <input name="limit" toolname-target="search.limit" type="number"> <button>Search</button> </form>
The bridge synthesizes an MCP tool descriptor from this form. Invoking the tool fills the targeted fields with the provided arguments and dispatches a submit event on the form.
Name collisions: if the page registers an imperative tool with the same name, the imperative registration wins.
Opt-out: set WEBMCP_DECLARATIVE_SCAN=false to disable DOM scanning and the MutationObserver entirely.
Troubleshooting
"Failed to parse input arguments" — caused by
document.modelContext.executeTool()failing on the page side. The monkey-patch bypasses this by calling the captured executor directly."detached Frame" — the page was navigated away or closed. The bridge auto-recovers by creating a new page. Re-run the tool call.
No tools discovered — ensure the page has called
document.modelContext.registerTool()with tool definitions. Usewebmcp_evaluateto calldocument.modelContext.getTools()manually.CORS errors in test tools —
test_fetch_titleusesfetch()which is subject to page CSP. Test on a permissive origin.
License
MIT