Hi, everyone.
I usually go by tanahiro2010 in Japan.
I'm a member of GDG Greater Kwansai.
I gave the first-half talk at the Google I/O Extended Osaka 2026 hands-on session, "Let's Build WebMCP and Call It from an AI Agent!"
This is a write-up of that hands-on session for Qiita (translated here for Dev.to).
Here's the codelab we used:
https://learn.gdgs.jp/webmcp-agent/
This is for people who already know MCP but have never heard of WebMCP, and for people who want to know "so what's this new spec Google put out, actually like?"
Let me just ask straight up: have you heard of WebMCP?
I hadn't even heard it existed until I started putting together the hands-on materials.
Just from the name, I assumed it was "the Web version of MCP."
But once I actually read the spec and implemented it, it turned out to be a much quirkier spec than I expected.
In this article I'll walk through what's actually in it, and what I learned by getting my hands dirty with it.
Before getting into WebMCP, let's recap MCP (Model Context Protocol).
MCP is a common interface for connecting external tools to an AI Agent.
The flow looks like this:
sequenceDiagram
participant Agent as AI Agent
participant Server as MCP Server
Agent->>Server: Launch (stdio / HTTP)
Server-->>Agent: tools/list (list of available tools)
Agent->>Server: tools/call (tool name + args)
Server->>Server: Execute the tool
Server-->>Agent: Return the result
The key point is that the tools stay registered for as long as the Agent is running.
Whether it's a local file operation or a tool that hits an internal API, you can keep calling it as long as you don't kill the Agent.
The webmcp-bridge-mcp
I built for this is also just an ordinary MCP Server listening on stdio.
From the perspective of a client like the Antigravity CLI, it's nothing more than "one more run-of-the-mill MCP Server."
What's unusual is the WebMCP side, which I'll get to next.
WebMCP is a draft spec published by the W3C Web Machine Learning Community Group.
https://webmachinelearning.github.io/webmcp/
As of writing, it's the February 2026 draft — still at the proposal stage.
In one sentence:
A mechanism by which a web page itself declares its own features as tools for an Agent.
There are two ways to register a tool.
You register a tool directly from JavaScript.
await document.modelContext.registerTool({
name: "reserve_hotel",
description: "Reserve a hotel",
inputSchema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
execute: async ({ city }) => ({ ok: true, city }),
});
The shape — name
/ description
/ inputSchema
/ execute
— is almost identical to an MCP tool definition.
Anyone who's touched MCP will look at this and immediately think, "oh, this is the same shape as that."
You turn an existing <form>
into a tool just by adding attributes to it.
<form toolname="search_hotels" tooldescription="Search hotels">
<input name="city" toolparamdescription="City to search hotels in" required />
<button type="submit">Search</button>
</form>
When a form with toolname
/ tooldescription
is found, a JSON Schema is automatically assembled from each <input>
's name
, required
, and toolparamdescription
(or, if that's absent, the text of the associated <label>
).
Here's the interesting part: internally, the declarative form gets normalized into the same registerTool()
call as the imperative form.
Rather than having two separate APIs, the declarative form is implemented as syntactic sugar over the imperative one.
Personally, I genuinely like this design.
The submission result is received via SubmitEvent#respondWith()
, as specified.
form.addEventListener("submit", (event) => {
event.preventDefault();
if (event.agentInvoked) {
event.respondWith(Promise.resolve({ ok: true /* ... */ }));
}
});
event.agentInvoked
lets you tell whether a human clicked the button or an Agent submitted the form.
It's a small detail, but it ends up mattering a lot later.
Because the name and the shape of the API are so similar, my initial impression was roughly "it's just the Web version of MCP."
But once I actually dug in, they turned out to differ clearly along three axes.
| Item | MCP | WebMCP |
|---|---|---|
| Target | AI Agents in general | Per the spec, mainly browser-embedded Agents |
| Registration timing | Once, at Agent startup | Every time the page is opened |
| Session lifetime | Until the Agent is terminated | Only while the page is open |
| Execution location | Local or a service server | Inside that browser, on that page |
The difference I felt most viscerally was the session lifetime.
stateDiagram-v2
[*] --> Unavailable
Unavailable --> Available: Open the tab
Available --> Unavailable: Close/leave the tab
Unavailable --> Available: Return to the tab
An MCP tool can be called as long as the Agent is up and running.
But a WebMCP tool is completely tied to "whether that page is currently open."
The moment you switch tabs, that tool disappears from view; go back, and it reappears.
The idea that "the tab's lifetime = the tool's lifetime" felt pretty fresh to someone coming from an MCP-only mindset.
As of this writing (August 2026), WebMCP is still a draft at the proposal stage.
Even Chrome's own docs introduce it as an "upcoming feature."
https://developer.chrome.com/docs/ai/webmcp?hl=ja
chrome://flags/#enable-webmcp-testing
In other words, you can't assume "the native implementation is enabled in every attendee's browser at the hands-on venue."
This is where the thing I built comes in.
There was a genuine gap between the hands-on requirements and the WebMCP spec if you approached it head-on.
So, I decided to build something to bridge the two.
With that in mind, I built a Chrome Extension and an MCP Server as a set.
There's one thing I was particular about here:
Only detect and execute the APIs defined by the WebMCP spec ( document.modelContext / annotated <form>s) as-is.
I didn't want to override the spec with some custom protocol of my own.
The reason is simple: if what people learn in the spec and what actually runs end up diverging, there's no point in running the hands-on in the first place.
Here's the overall structure:
flowchart LR
Agent["AI Agent<br>(e.g. Antigravity CLI)"]
MCP["webmcp-bridge-mcp<br>(MCP Server)"]
Ext["webmcp-bridge-extension<br>(Chrome Extension)"]
Page["Web page<br>(WebMCP-enabled)"]
Agent <-->|stdio, MCP| MCP
MCP <-->|WebSocket| Ext
Ext <-->|content/injected script| Page
The MCP Server (webmcp-bridge-mcp
) never touches the DOM directly itself.
It sticks strictly to being a Bridge / Registry / Router between itself and the Extension, and leaves all DOM manipulation to the Extension side.
It's a fairly unglamorous division of labor, but the responsibilities are clear, and I had few doubts while implementing it.
The WebSocket binds to ws://127.0.0.1:58787
.
I used 58787 instead of 8787 because 8787 collided with the default port for wrangler dev
(Cloudflare Workers).
When I had Workers development running in parallel, the Extension would end up connecting to wrangler instead of my server, and I'd sit there wondering why nothing would connect — a fairly unglamorous bug I ran into early on.
The Extension side is MV3-based and has a two-layer structure.
flowchart TB
subgraph Page["Inside the web page"]
direction TB
Injected["injected.ts<br>(main world)"]
Content["content.ts<br>(isolated world)"]
Injected <-->|postMessage| Content
end
Background["background.ts<br>(Service Worker)"]
WS["MCP Server"]
Content <-->|chrome.runtime| Background
Background <-->|WebSocket| WS
The reason injected.ts
needs to run in the main world is that accessing the page's document.modelContext
requires running in the main world.
You can't touch it directly from the isolated world (a normal content script).
When document.modelContext
isn't yet natively implemented in the browser, injected.ts
provides a minimal polyfill for registerTool
/ getTools
/ executeTool
/ the toolchange
event.
If a native implementation exists, it does nothing.
By designing it as "quietly defer if a native implementation exists," I expect I won't need major code changes even as native implementations roll out.
There are six tools visible to the Agent:
| Tool | Description |
|---|---|
webmcp_get_status |
|
| Returns the Extension's connection state, number of known tabs, and the active tab ID | |
webmcp_list_tabs |
|
| Returns the list of WebMCP-enabled tabs the Extension has captured | |
webmcp_discover_tools |
|
| Discovers the WebMCP tools on a given tab | |
webmcp_call_tool |
|
| Executes a tool on a given tab | |
webmcp_submit_tool |
|
| Confirms a submission that's waiting on a human, from the Agent side | |
webmcp_ping |
|
| Checks connectivity with the Extension |
Here's what the input/output look like:
// webmcp_discover_tools input
{ "tabId": 123, "forceRefresh": true }
// output
{ "tabId": 123, "tools": [ { "id": "reserve_hotel", "name": "reserve_hotel", "source": "imperative" } ] }
// webmcp_call_tool input
{ "toolId": "reserve_hotel", "args": { "city": "Osaka" } }
// output
{ "ok": true, "result": { "ok": true, "city": "Osaka", "confirmationId": "RES-12345" } }
webmcp_submit_tool
is a bit unusual.
Per the spec, a declarative form without toolautosubmit
is expected to stop by focusing the submit button, so that a human reviews the content and submits manually.
This is a safety mechanism intentionally built into the WebMCP spec.
webmcp_submit_tool
is a tool for explicitly overriding that from the Agent side.
I've written a note in the README that this should only be used with the understanding that it bypasses the human confirmation the spec intends.
This is the part I most wanted to write about in this article.
Things I never would have noticed just from reading the docs kept popping up once I actually ran it on Chrome for Testing.
getTools()
turned out to be async From skimming the summary in Chrome's developer docs, my impression was that it was a synchronous function.
But when I checked on real hardware (Chrome for Testing 150), document.modelContext.getTools()
returned a Promise<ModelContextTool[]>
.
On top of that, executeTool()
doesn't take a tool-name string — it requires the actual tool object obtained from getTools()
.
Pass it a string, and you get a TypeError
.
This was a spot where implementing based only on a summary of the docs would trip you up, plain and simple.
file://
, the handshake never finishes, for some reason
For postMessage
between content.ts
and injected.ts
, I was using window.location.origin
as targetOrigin
.
On a page opened via file://
, this becomes, for some reason, the literal string "null"
.
As a result, the handshake never completed at all, and the overlay would never show up.
I'm not sure whether this bug is fully reproducible, but thinking about it more, communication between the main world and isolated world within the same window shouldn't involve the concept of cross-origin at all in the first place.
So I changed targetOrigin
to "*"
, and instead guarantee legitimacy via a random channel ID.
This was the type of bug you only notice by trying it with file://
; if I hadn't verified the sample page by opening it directly via file://
, I probably would have missed it.
When a native implementation is present, the browser itself may automatically register declarative forms.
In that case, this Extension's own registerTool()
call fails as a "duplicate," but I'm treating that as expected behavior.
Since findAnnotatedFormByName()
looks directly at the DOM to determine source: "declarative"
, it can report correctly regardless of who registered it.
There were also cases where the natively-synthesized inputSchema
returned empty ({ type: "object", properties: {} }
) on this particular build.
This is probably down to the state of the browser's implementation, not a bug in the Extension.
The WebMCP spec's execute
is originally meant to return "a string summary for the agent."
Because of that, I confirmed on real hardware that even when the page returns an object like { ok: true, city }
, the browser's native implementation JSON-stringifies it before returning.
Neither the Extension nor the MCP Server touch result
— they pass it straight through.
So the MCP client side needs to determine whether it got a string or structured data.
An MV3 Service Worker gets suspended when it goes idle.
During that time, the WebSocket connection also drops.
It wakes back up automatically when a chrome.tabs
event or similar fires, and the reconnection logic kicks in.
But if nothing happens right after startup — no tab activity at all — it can stay stuck at extensionConnected: false
for a while.
If webmcp_get_status
/ webmcp_ping
return false
, doing something with the target tab (switching to it, re it, etc.) will bring it back.
On declarative-only pages (with no imperative JS at all), there was a race condition where tool registration could complete before the handshake with content.ts
finished, leaving the overlay permanently hidden.
injected.ts
registers <form toolname tooldescription>
elements as soon as it finds them via MutationObserver
.
So this race is more likely to occur on lightweight declarative-only pages that don't run any imperative script.
The previous implementation recorded "the manifest was sent / attempted to be sent" before the handshake had actually completed.
As a result, once the handshake did complete, a resend request would be misjudged as "no diff" and silently swallowed.
I fixed reportManifestIfChanged()
so that it doesn't record or send anything at all until the channel is established.
I wrote a test that deliberately reproduces the bad ordering (tool registration → delayed handshake), and confirmed it reproduces the bug before the fix and resolves it after.
More than the bug itself, what I felt made the biggest difference was the approach of "write a test that deliberately reproduces the bad ordering, and use it to prove the fix works."
Per the spec, a form without toolautosubmit
stops by just focusing the submit button.
This is meant so a human reviews the content and submits manually.
At least some native implementations keep executeTool()
itself blocked during this "waiting for a human" period.
This is not the same behavior as this Extension's polyfill, which immediately returns { pending: true, ... }
.
If you call webmcp_call_tool
on a tool without toolautosubmit
in an automated environment with no human interaction, you won't get a response back until it times out.
Before calling it, you need to check whether webmcp_discover_tools
's result has requiresUserGesture: true
.
I verified the following four scenarios:
<form>
)registerTool()
calls)unlock
makes a new tool dynamically appear, and lock
makes it disappear)I actually loaded the Extension on Chrome for Testing and confirmed all patterns passed.
The fourth one also serves as verification of dynamic detection via the toolchange
event and MutationObserver
.
Since webmcp_discover_tools
returns a cached result by default, you need forceRefresh: true
to observe these dynamic changes.
I'm using Chrome for Testing (the Chromium bundled with Playwright) because, from Chrome 137 onward, official Google Chrome builds have removed the --load-extension
flag for automation purposes.
an extension manually via chrome://extensions
works fine with regular Chrome.
But if you want to load an extension in automated tests, you need Chrome for Testing or Chromium.
On the MCP Server side, I use a mock class called FakeExtension
.
It speaks the same WebSocket protocol as the real Extension, and lets me verify — without launching a browser — the connection state, the discover_tools cache/forceRefresh
behavior, concurrent tool call execution, and cleanup on disconnect.
Since it doesn't launch a browser, it runs fast, so I split the work: bridge logic gets tested here, and verification that involves actual DOM manipulation goes through the Extension's Playwright tests.
postMessage
between the main world (injected.ts
) and the isolated world (content.ts
), a random channel ID is issued once per page load, shared through a one-time handshake, and attached to every subsequent message. This is to prevent unrelated page scripts from injecting a fake manifest or fake execution results.127.0.0.1
, so it can't be connected to from anywhere other than the same machine.This is built with personal use and prototyping in mind, and doesn't include additional authorization such as token authentication.
I stopped at the line of "good enough to run at the hands-on session" and haven't designed authorization with production use in mind.
Even though I put this together somewhat forcefully, there are benefits I genuinely felt after actually using it.
From here on, this is entirely my personal opinion.
Honestly, having implemented it and used it at the hands-on session, what I felt most strongly was, "do we really need this?"
There are a few reasons.
First, the motivation to use a browser-embedded Agent is still weak.
Actually, I myself didn't even know Chrome had a built-in Agent until I did this investigation.
I imagine there are quite a few people in the same boat.
And WebMCP has the constraint that it only works while the page is open.
That's subtly restrictive.
Having a tool disappear the moment you switch tabs felt a bit inconvenient, coming from the mindset I'm used to with MCP.
In actual use, I didn't feel a particularly large difference in perceived token usage or execution speed between Agent-driven browser operations and WebMCP-driven ones.
For people who keep an existing Agent like Claude Code or Codex open at all times, I felt the appeal of going out of your way to open a browser-embedded Agent just to use this is pretty thin.
And honestly, from a layperson's perspective, I also wondered if the target audience selection itself might be a bit off.
The benefit itself — "fewer mistaken operations from the Agent" — is appealing.
But it's a bit of a shame that the place where you can realize that benefit is limited to something as little-known as a "browser-embedded Agent."
That said, for people who use a browser-embedded Agent as part of their daily routine, the two points — "less guessing" and "shorter execution time" — should genuinely land hard.
I think the reason it didn't land as hard for me this time is simply that my own workflow wasn't built around a browser-embedded Agent to begin with.
Given how harsh some of this has been, you might wonder why I'm publishing it at all.
The reason is simple.
It felt like a waste to build an Extension and an MCP Server and have them only get used on the day of the hands-on session and nothing more.
Even though I have some skepticism about WebMCP as a spec, the technical insights I only gained by actually implementing it and getting my hands dirty — the async API, the origin issue, how I fixed the race condition, and so on — should be useful to someone on their own merits.
Also, if people end up using the extension or the MCP server, that makes me happy.
WebMCP is, directionally, an interesting spec: "the web page itself declares tools for an Agent."
That said, as of August 2026 it's still at the draft stage, and its target is limited to browser-embedded Agents.
So my personal conclusion is that, at this point, it doesn't land all that strongly for people who are already heavy users of existing MCP-capable Agents.
On the other hand, the pitfalls I found while implementing it — the async API, the origin issue, the race condition — should be useful reference material as native implementations spread further going forward.
Here's what I built and the related links:
Feel free to check out the repos and reach out with any questions.