Skip to Content
DocsWebMCP

Driving the webapp with an agent

The web client publishes its own actions as WebMCP  tools, so an agent can open a share, walk it, read files, seed them and publish new ones without installing anything.

That is the point of doing it in a browser. The native path needs the Rust binary; npx agent-share needs the node-datachannel addon, because Node has no RTCPeerConnection. A browser already has WebRTC, a sandbox and storage. The tab becomes the runtime.

Registration lives in packages/agent-share-web/src/lib/webmcp/. It is feature-detected, so on a browser without WebMCP — which today is every browser by default — it reads one property and does nothing.

What you need

  • Chrome 150 or newer, started with --enable-features=WebMCP.
  • chrome-devtools-mcp, which is what actually carries the tools to an agent. It launches Chrome itself, so it can turn the feature on for you.

WebMCP is a W3C Community Group draft, not a standard. Chrome is the only implementation; Edge, Firefox and Safari have nothing. And the spec only covers publishing tools — getTools() and executeTool() are still marked TODO: Spec and describe, and the explainer assumes an agent built into the browser. So the page half below is stable, and the connection half is not.

Setup

claude mcp add chrome-devtools-webmcp --scope user -- \ npx -y chrome-devtools-mcp@latest \ --categoryExperimentalWebmcp \ --chromeArg=--enable-features=WebMCP

Both flags are load-bearing and neither implies the other: --categoryExperimentalWebmcp exposes the two MCP tools, and --chromeArg=--enable-features=WebMCP turns on the browser feature they read. With only the first, the tools exist and always return an empty list.

If you already have the chrome-devtools-mcp plugin installed, add this as a separate entry rather than editing the plugin: its arguments are fixed and a plugin update would overwrite them.

Using it

navigate_page https://agent-share.dev/app/files/<ticket> list_webmcp_tools -> the six tools below execute_webmcp_tool { toolName: "read", input: "{\"path\":\"src/lib.rs\"}" }

The ticket in the page URL is the default for every tool, so once you have navigated you can call connect with no arguments at all.

The tools

ToolDoesChanges state
connectOpen a share; report its contents and the live connectionno
listA directory’s entries, or one file’s size and local coverageno
readRead a byte window of one fileno
searchFind a string across the share’s text filesno
syncPull files into browser storage; this tab then seeds themyes
publishPublish files as a new share and return a ticketyes

They work on any route and need nobody present. There is nothing here that moves the page, selects a file, or switches a view, and that is the line the surface is drawn on: WebMCP is the agent’s interface, the screen is the person’s. An agent that could move a tab out from under someone reading it has been handed a capability nobody asked it to have, and the tools it would need — a selection, a current view — are meaningless to a caller that cannot see them anyway.

Two capabilities are therefore absent rather than broken. Nothing here downloads to a filesystem or mirrors into a local folder: both need showSaveFilePicker or showDirectoryPicker, which only a real click can open, and no directory handle is persisted anywhere. For bulk transfer to a filesystem, use agent-share mirror. To pull bytes into the browser instead — where later reads are local and the tab starts seeding — use sync.

Every result is flat and carries ok. A failure is { ok: false, code, error } with a stable code — not_found, unauthorized, bad_argument, not_a_file, no_session, unsupported, failed.

Opening and re-opening

connect is idempotent. The first call dials; a later one refreshes the file list and re-reads the connection, so it doubles as a status check and there is no separate status tool:

{ "ok": true, "ticket": "SvL6pH…", "files": 128, "directories": 12, "bytes": 4210433, "transport": "webrtc", "closed": false, "peersOnMesh": 3, "peersDirect": 1, "maxDirect": 4, "filesHeldLocally": 0, "skippedEntries": 0 }

Listing

list answers for whatever the path names, and says which it got in kind. A directory returns entries depth levels deep, plus the rollup of everything beneath it; a file returns its size and how much of it this browser already holds. depth means nothing on a file and is ignored there:

{ "ok": true, "kind": "dir", "path": "src", "entries": [ … ], "count": 9, "files": 40, "bytes": 210433 } { "ok": true, "kind": "file", "path": "src/lib.rs", "name": "lib.rs", "size": 210433, "mtime": 1755000000, "index": 7, "held": false, "coverage": 0.25 }

The rollup is free: buildTree totals every directory as it builds one, so asking how big a folder is never walks it.

Reading a file

read is windowed. It returns UTF-8 text, or data as base64 when the bytes are binary, plus size, eof and nextOffset. Follow nextOffset until eof is true:

{ "ok": true, "path": "src/lib.rs", "encoding": "utf8", "text": "fn main() {…", "offset": 0, "length": 65536, "size": 210433, "eof": false, "nextOffset": 65536 }

The default window is 64 KiB and the maximum is 256 KiB, which is the protocol’s own cap on a READ. Bulk transfer is deliberately not a tool: the browser cannot write to your filesystem without a person clicking a folder picker, so moving a large tree is still agent-share mirror’s job.

Publishing

publish writes into the origin private file system and serves it with the same producer the Add folder button uses, so no user gesture is involved and the result is an ordinary share a native peer can read:

publish { files: [{ path: "notes.md", content: "# hi\n" }] } -> { ticket: "SvL6pH…", url: "https://…/app/files/SvL6pH…", files: 1 } agent-share mirror SvL6pH… ./out # works from the CLI

It cannot share a folder that already exists on your disk — that needs showDirectoryPicker() and a real click. And the share lives only as long as the tab: the bytes sit in per-origin browser storage, under that origin’s quota and subject to eviction.

Telling the user an agent is here

The name of the app in the top bar carries it. agent-share turns green the first time any tool is called and stays green; while a call is running, or for ten seconds after one, its characters shimmer between full and dimmed green. Hovering gives the sentence — which tool, how many times, how long ago — and before anything has happened it says how many tools are published, or that this browser has no WebMCP at all. See packages/agent-share-web/src/components/brand.

The wording is careful, because the thing you would want to show cannot be observed. WebMCP lets a page publish tools; it never tells the page that something has connected to them, and the spec has no notion of an agent session at all. getTools() is a caller’s API — a page calling it learns about its own tools, not about who else is looking. A tab whose tools nobody has ever called is indistinguishable from a tab no agent has found.

So the colour is driven by the only real evidence, a tool actually being invoked, and it never claims more than that. The name is untouched until the first call, and once the active window lapses the tooltip drops the present tense and says outright that the agent may no longer be attached. Do not “improve” it into a connected/disconnected indicator; there is nothing to drive one with.

Two states rather than three, and green never decays back to grey: the page cannot un-know that an agent was here, so only the movement is allowed to stop.

Browser behaviour worth knowing

Measured against Chrome 151, and the reason parts of the implementation look the way they do. Some of it contradicts the published examples.

  • document.modelContext, not navigator.modelContext. The navigator alias still exists but is deprecated as of Chrome 150 — the same version that the WebMCP tooling requires.
  • executeTool takes a RegisteredTool, not a name. Get the object from getTools() first. Passing a string throws The provided value is not of type 'RegisteredTool'.
  • Its input must be a JSON string. An object throws Failed to parse input arguments.
  • Input is never validated against inputSchema. A call omitting a required field runs anyway, with that field undefined. Every tool therefore checks its own arguments.
  • A throw is flattened. Whatever a tool throws reaches the agent as UnknownError: Tool was executed but the invocation failed, with the message stripped. So failures are returned as data instead — hence ok: false.
  • Return values are serialized to a string. Returning a plain object is fine; it arrives JSON-encoded.
  • Registration is per-call and asynchronous. The tools are registered concurrently, because until the last one lands getTools() returns a partial list and says nothing about being incomplete.
  • Unregistering is only possible through an AbortSignal given at registration time.

Checking it works

Without any agent, open the page in Chrome 150+ with the feature flag and look at DevTools → Application → WebMCP. Available Tools lists what the page published; Invoked Tools logs each call with its input and output.

From the console:

const tools = await document.modelContext.getTools() await document.modelContext.executeTool( tools.find(t => t.name === 'list'), '{}')