Dev.to · 18 min read

I Built a Bridge for Google's New WebMCP Draft Spec — Here's What Broke

I Built a Bridge for Google's New WebMCP Draft Spec — Here's What Broke

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?" Have you heard of WebMCP? 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. First, what is MCP? 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. What is WebMCP? 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. Imperative 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." Declarative You turn an existing into a tool just by adding attributes to it. Search When a form with toolname / tooldescription is found, a JSON Schema is automatically assembled from each 's name, required, and toolparamdescription (or, if that's absent, the text of the associated ). 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. MCP and WebMCP: similar names, but... 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. How much of it actually works, as of August 2026 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 You can join the Origin Trial starting with Chrome 149 To just try it locally, use chrome://flags/#enable-webmcp-testing It's explicitly noted that the API may still change 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. What I built — WebMCP Bridge There was a genuine gap between the hands-on requirements and the WebMCP spec if you approached it head-on. What the spec assumes: WebMCP is for browser-embedded Agents The reality of the hands-on: participants wanted to call it from an existing MCP-capable Agent, like the Antigravity CLI 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. MCP Server: https://github.com/tanahiro2010/webmcp-bridge-mcp Chrome Extension: https://github.com/tanahiro2010/webmcp-bridge-extension There's one thing I was particular about here: Only detect and execute the APIs defined by the WebMCP spec (document.modelContext / annotated 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. Overview of the Bridge Here's the overall structure: flowchart LR Agent["AI Agent(e.g. Antigravity CLI)"] MCP["webmcp-bridge-mcp(MCP Server)"] Ext["webmcp-bridge-extension(Chrome Extension)"] Page["Web page(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(main world)"] Content["content.ts(isolated world)"] Injected |postMessage| Content end Background["background.ts(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. Tools visible to the Agent 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. Running it on real hardware, it was riddled with landmines 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. 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. On 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. The native implementation registers declarative forms on its own 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. Tool execution results come back as strings, for some reason 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. The Service Worker falls asleep on its own 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, reloading it, etc.) will bring it back. Tool registration finishes before the handshake, so the overlay never shows up 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 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." A tool that never comes back until a human clicks 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. Testing: two tiers, with and without a browser I verified the following four scenarios: An imperative-only page (no ) A declarative-only page (no registerTool() calls) A page with a normal mix of imperative and declarative A pattern where executing one tool changes whether another tool exists (calling 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. Loading 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. A note on security (this is a prototype) For 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. The MCP Server's WebSocket server binds only to 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 so, there are real benefits Even though I put this together somewhat forcefully, there are benefits I genuinely felt after actually using it. Less guessing: the Agent doesn't have to infer the meaning of buttons or input fields on screen. The site itself can declare its intent as a tool. Shorter execution: no need to replay a bunch of clicks and inputs — it can be handled directly as a tool call. Visually verifiable: since it runs in the browser, a human can watch the result happen in real time. But personally, I don't think it's necessary 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, it will resonate with the right people 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. Why I published this article anyway 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. Summary 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: Codelab: https://learn.gdgs.jp/webmcp-agent/ WebMCP Bridge MCP: https://github.com/tanahiro2010/webmcp-bridge-mcp WebMCP Bridge Extension: https://github.com/tanahiro2010/webmcp-bridge-extension connpass: https://gdgkwansai.connpass.com/event/391029 Original Article: https://qiita.com/tanahiro2010/items/66b7822e2132ec4e0a45 Feel free to check out the repos and reach out with any questions.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News