mcp-webbrowser - expose opted-in browser tabs to MCP
mcp-webbrowser --stdio mcp-webbrowser --print-url mcp-webbrowser --stdio --bind 127.0.0.1:31741 mcp-webbrowser --connect http://host:port/TOKEN/browser
The companion userscript lets you explicitly grant an MCP client access to individual browser tabs. Only opted-in tabs connect to this broker over WebSocket; MCP clients can then discover those tabs, execute JavaScript in their page context and add DOM elements. Code can inspect or change the page, including logged-in content and forms, and cause side effects. Treat allowing a tab as giving the MCP client access to that page, not merely its URL or title. The broker still applies its own MCP permission rules.
This executable is the broker, not a browser extension. The userscript handles per-tab authorization and page execution. The broker cannot see unapproved tabs merely because the userscript is installed.
The HTTP/WebSocket listener stays active alongside MCP stdio or an outgoing
--connect. Listener configuration, token derivation and URL discovery follow
mcp-vim. Tokens authenticate both MCP HTTP and browser WebSocket connections.
Keep the broker URL private. The userscript accepts the http:// URL printed
by mcp-webbrowser --print-url, converts it to WebSocket internally and
appends its own opaque per-tab target ID.
The self-contained userscript is webbrowser/mcp-webbrowser.user.js in the
AnyEvent::MCP source distribution. Installing the Perl distribution does
not install it in a browser. Install a userscript manager first; see the
ScriptCat installation guide.
ScriptCat on Chromium and Firefox has been tested (Firefox 153 on Linux,
including page execution, DOM insertion and navigation). ScriptCat is available
for Chrome, Edge and Firefox. Tampermonkey
provides the required GM_getTab/GM_saveTab APIs and may work, but
has not been tested with this userscript. Other managers are unverified.
Install the source-distribution file through your userscript manager, or open the published userscript URL in the browser and install it there:
https://dist.schmorp.de/mcp-webbrowser/1/mcp-webbrowser.user.js
The /1/ is the major version of the private browser/broker protocol, not
the MCP protocol or Perl distribution version. The published script declares
that URL for installation and automatic updates; updates depend on the
userscript manager. The source file contains the entire userscript and
needs no external JavaScript import.
Start the broker (directly or through your MCP client/multibroker) and obtain its listener URL with:
mcp-webbrowser --stdio mcp-webbrowser --print-url
The second command prints the URL and exits; run it with the same service
name and configuration as the running broker. It does not launch a second
listener. On a page you want to expose, select Allow MCP access to this
tab from the userscript menu. The first time, enter the full URL printed
by --print-url when prompted. Do not append a tab name or convert
http to ws yourself. The URL is remembered across tabs; the grant is
per tab, not per website. Pages remain unexposed until explicitly allowed,
and the menu on an unapproved tab contains only the Allow command.
When allowed, the menu offers Disconnect MCP access to this tab, the
broker URL configuration and independent toggles for a tab-title indicator
and a notification in the page when switching to that tab. Both indicators
are enabled by default. The title prefix is [○MCP] while connecting,
[●MCP] when connected and [⊗MCP] after prolonged failure. The toast
shows the same status and disappears shortly after a successful connection.
The indicator prefix is omitted from titles reported to the broker.
The permission remains granted during temporary disconnections and normally
survives navigation or reload until explicitly revoked.
If a tab does not connect, check that the broker is running and its URL is correct. Depending on browser settings, you may also need to allow the page to access a local-loopback or local-network address; browser permission blocking can look like a WebSocket failure. The title and toast indicators report connectivity, not a trustworthy security boundary.
Note: ScriptCat currently loses the saved per-tab permission when Chromium replaces a discarded tab. After such a discard and restoration, you must choose Allow again. This is tracked in https://github.com/scriptscat/scriptcat/issues/1791.
After allowing a tab, ask your assistant:
Call tabs and show the connected tabs. On one of them, execute document.title and show its result.
Use a target returned by tabs for the execute call. Listing
requires read permission; even this simple JavaScript expression needs
exec permission, because execution has full page authority. The
tool returns an object containing value; there is no read-only mode.
With a multibroker prefix browser, the tools are browser.tabs
and browser.execute. If no tab is listed, confirm that the userscript
was allowed on that tab and that its indicator shows a connection.
Installing the userscript does not grant access to any tabs.
An execution error or timeout does not prove that nothing happened;
do not blindly retry JavaScript that changes page state.
Return {tabs: [...]} with each connected tab's target, current document
instance_id, integer revision, and client-reported url and title.
The revision starts at 1 and advances on document replacement, not on an
ordinary WebSocket reconnect or a same-document navigation. Disconnected tab
identities are retained for up to 30 seconds. This is cached registration
metadata, not a fresh DOM inspection. Requires read permission.
Run code on target. Optional instance_id and revision from tabs
reject a replaced document before dispatch. timeout defaults to 30 seconds
and can be 1..300 seconds. With navigate false (the default), the browser
awaits the evaluation result. Results are {value: JSON_VALUE}; exceptions
become tool errors.
Replies default to a 16 KiB UTF-8 JSON limit; max_bytes can raise the
limit to 2 MiB (minimum 1024 bytes). The size includes the JSON envelope.
Oversized results yield an error instead of truncating JSON or disconnecting
the browser. Code has already executed when the size error is returned;
do not blindly retry commands with side effects.
With navigate true, the broker sends the command, awaits the browser's
ready message, and sends proceed before the browser evaluates it.
The broker then waits for either a new document registering on the same tab
or an observed same-document navigation. The result has navigation equal
to document or same-document, the resulting revision, and reported
URL/title. If neither completion is observed before the timeout, it returns
navigation: "timeout" with a reason, current revision and available metadata.
Timeout does not imply that the command was not executed; no automatic retry
is attempted. One navigation operation at a time is allowed per tab.
The browser client injects code as the body of async () => code
in the page's JavaScript world. A single expression yields its value without
return; use a braced function body with explicit return for multiple
statements. Parenthesize object literals, e.g. ({title: document.title}).
await works inside either form. Declarations in the body do not persist
between calls unless assigned to page globals. Results are awaited. This
does not use eval or Function. Syntax errors can prevent the injected
script from starting; a temporary page-world error listener tries to return
the native syntax-error message, otherwise the client reports an injection
failure. Source injection is serialized until each script acknowledges
successful compilation or fails; asynchronous execution thereafter may
overlap. Code has full page-context authority, with no read-only evaluation
mode or sandbox claim.
Cancellation and timeout send a best-effort cancel message. They cannot undo already executed code or guarantee that JavaScript stops. Disconnect fails ordinary pending calls without replaying them. A navigation request persists across disconnects and may complete on the next document's registration. Concurrent human navigation can satisfy a pending request; success reports observation, not proof that the submitted code caused the navigation.
Insert an element through the userscript's GM_addElement in the content
sandbox. This can bypass page CSP for stylesheets and scripts. Required
target and tag; optional parent is a document CSS selector
(default: document root). attributes contains HTML attribute strings,
while properties contains DOM property values, such as textContent
or innerHTML. Properties are supplied during element creation; attributes
are set explicitly as well. An unsuccessful selector or insertion is an error.
The result is {value: {tag: TAG, id: ID_OR_NULL}} and does not assert that
the caller's id is unique. No automatic ID or registry is maintained: give
elements a distinctive id or data-* attribute when later page-context
execute calls need to modify or remove them. Uses exec permission and
supports the same optional instance_id and revision guards as execute.
The default rule file is ~/.config/anyevent-mcp/rules/webbrowser-default.ini,
under $XDG_CONFIG_HOME when set. --name NAME replaces default.
read covers tab discovery; exec covers arbitrary JavaScript.
Exec prompts offer this exact code in this target, any code in this target,
or any code in all targets. Saved target grants also cover future tabs using
the same target name. Client-reported URLs and titles are display information,
not verified origin identities or saved permission scopes.
--ask selects the same helper, tty, yad, mcp or auto modes as other servers.
Common options, including --name, --bind, --token,
--stdio, --connect, --print-url, --auto-allow and
--auto-deny, are documented in
mcp_getoptions in AnyEvent::MCP::Server::Simple. This broker retains its
HTTP/WebSocket listener when serving MCP over stdio or --connect.
--tool-help prints registered tools and arguments without listening.
--auto-allow '*,*' approves both discovery and JavaScript execution:
only explicitly opted-in tabs are exposed, but code has full authority
in those pages.
WebSocket authentication happens at /TOKEN/TARGET. TARGET is one
percent-encoded path component. Before ordinary messages, send:
["mcp-webbrowser", 1, 0, INSTANCE_ID, {"url": URL, "title": TITLE}]
INSTANCE_ID identifies one loaded document. Keep it across reconnects of that
document and change it on navigation/reload. The private browser protocol is
1.0, independent of MCP and the old xpcse browser-transfer protocol.
The broker answers ["welcome", TARGET, INSTANCE_ID, REVISION].
The requested target represents a browser tab: a different document with the
same target replaces its previous connection and increments the revision.
Reconnecting the same document retains that revision. A disconnected tab's
identity expires after 30 seconds with no pending navigation. No page is
exposed before a valid hello.
A tab session starts on first hello, remains active across temporary socket
disconnects and document replacements, and finishes on an explicit quit,
after 30 seconds of inactivity, or when the broker stops. Session start
and finish are logged at info; ordinary socket connection and disconnection
events are logged at trace. Revision and request IDs are transmitted as
JSON numbers, even when Perl string operations have touched those scalars.
Approved JavaScript is logged at trace when dispatched for ordinary evaluation,
or when proceed is sent for navigation (never during the permission prompt).
Broker to browser:
["call", ID, "execute", {"code": CODE, "max_bytes": BYTES}]
["call", ID, "navigate", {"code": CODE, "max_bytes": BYTES}]
["call", ID, "add_element", {"tag": TAG, "parent": SELECTOR, "attributes": ATTRS, "properties": PROPS}]
["proceed", ID]
["cancel", ID]
Browser to broker:
["reply", ID, true, JSON_VALUE]
["reply", ID, false, ERROR_STRING]
["ready", ID]
["navigated", ID, {"url": URL, "title": TITLE}]
["state", {"url": URL, "title": TITLE}]
["quit"]
The browser replies ready to a navigate call without evaluating it.
Only proceed starts evaluation. navigated reports a completed
same-document navigation; a different document's hello is the other
completion path. reply with false can report an evaluation failure.
Only JSON values cross the wire; undefined becomes null. Errors are strings.
Late replies after cancellation are ignored. State updates replace cached
metadata. Malformed messages close the connection with status 1002.
The initial hello has a 15-second deadline; a silent connection is closed.
The distributed userscript evaluates in the page's JavaScript world via an injected script and returns serialized results through DOM events. Its Navigation API listener is installed in that same world. The broker cannot authenticate page-reported values or infer application-rendering readiness from navigation success. The userscript retries disconnected WebSockets; it has no event subscriptions or separate SPA revision counter.