{"slug": "i-built-a-bridge-for-google-s-new-webmcp-draft-spec-here-s-what-broke", "title": "I Built a Bridge for Google's New WebMCP Draft Spec — Here's What Broke", "summary": "A developer known as tanahiro2010, a member of GDG Greater Kwansai, built a bridge for Google's new WebMCP draft spec and shared insights from a hands-on session at Google I/O Extended Osaka 2026. The bridge, named webmcp-bridge-mcp, is an MCP server that connects AI agents to WebMCP tools, which allow web pages to declare their own features as tools. The developer found the spec quirky but praised its design, noting that declarative forms are normalized into the same registerTool() call as imperative JavaScript registration.", "body_md": "Hi, everyone.\n\nI usually go by **tanahiro2010** in Japan.\n\nI'm a member of GDG Greater Kwansai.\n\nI 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!\"\n\nThis is a write-up of that hands-on session for Qiita (translated here for Dev.to).\n\nHere's the codelab we used:\n\n[https://learn.gdgs.jp/webmcp-agent/](https://learn.gdgs.jp/webmcp-agent/)\n\nThis 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?\"\n\nLet me just ask straight up: have you heard of WebMCP?\n\nI hadn't even heard it existed until I started putting together the hands-on materials.\n\nJust from the name, I assumed it was \"the Web version of MCP.\"\n\nBut once I actually read the spec and implemented it, it turned out to be a much quirkier spec than I expected.\n\nIn this article I'll walk through what's actually in it, and what I learned by getting my hands dirty with it.\n\nBefore getting into WebMCP, let's recap MCP (Model Context Protocol).\n\nMCP is a common interface for connecting external tools to an AI Agent.\n\nThe flow looks like this:\n\n```\nsequenceDiagram\n    participant Agent as AI Agent\n    participant Server as MCP Server\n    Agent->>Server: Launch (stdio / HTTP)\n    Server-->>Agent: tools/list (list of available tools)\n    Agent->>Server: tools/call (tool name + args)\n    Server->>Server: Execute the tool\n    Server-->>Agent: Return the result\n```\n\nThe key point is that **the tools stay registered for as long as the Agent is running**.\n\nWhether 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.\n\nThe `webmcp-bridge-mcp`\n\nI built for this is also just an ordinary MCP Server listening on stdio.\n\nFrom the perspective of a client like the Antigravity CLI, it's nothing more than \"one more run-of-the-mill MCP Server.\"\n\nWhat's unusual is the WebMCP side, which I'll get to next.\n\nWebMCP is a draft spec published by the W3C Web Machine Learning Community Group.\n\n[https://webmachinelearning.github.io/webmcp/](https://webmachinelearning.github.io/webmcp/)\n\nAs of writing, it's the February 2026 draft — still at the proposal stage.\n\nIn one sentence:\n\nA mechanism by which a web page itself declares its own features as tools for an Agent.\n\nThere are two ways to register a tool.\n\nYou register a tool directly from JavaScript.\n\n```\nawait document.modelContext.registerTool({\n  name: \"reserve_hotel\",\n  description: \"Reserve a hotel\",\n  inputSchema: {\n    type: \"object\",\n    properties: { city: { type: \"string\" } },\n    required: [\"city\"],\n  },\n  execute: async ({ city }) => ({ ok: true, city }),\n});\n```\n\nThe shape — `name`\n\n/ `description`\n\n/ `inputSchema`\n\n/ `execute`\n\n— is almost identical to an MCP tool definition.\n\nAnyone who's touched MCP will look at this and immediately think, \"oh, this is the same shape as that.\"\n\nYou turn an existing `<form>`\n\ninto a tool just by adding attributes to it.\n\n```\n<form toolname=\"search_hotels\" tooldescription=\"Search hotels\">\n  <input name=\"city\" toolparamdescription=\"City to search hotels in\" required />\n  <button type=\"submit\">Search</button>\n</form>\n```\n\nWhen a form with `toolname`\n\n/ `tooldescription`\n\nis found, a JSON Schema is automatically assembled from each `<input>`\n\n's `name`\n\n, `required`\n\n, and `toolparamdescription`\n\n(or, if that's absent, the text of the associated `<label>`\n\n).\n\nHere's the interesting part: internally, the declarative form gets normalized into the same `registerTool()`\n\ncall as the imperative form.\n\nRather than having two separate APIs, the declarative form is implemented as syntactic sugar over the imperative one.\n\nPersonally, I genuinely like this design.\n\nThe submission result is received via `SubmitEvent#respondWith()`\n\n, as specified.\n\n``` js\nform.addEventListener(\"submit\", (event) => {\n  event.preventDefault();\n  if (event.agentInvoked) {\n    event.respondWith(Promise.resolve({ ok: true /* ... */ }));\n  }\n});\n```\n\n`event.agentInvoked`\n\nlets you tell whether a human clicked the button or an Agent submitted the form.\n\nIt's a small detail, but it ends up mattering a lot later.\n\nBecause the name and the shape of the API are so similar, my initial impression was roughly \"it's just the Web version of MCP.\"\n\nBut once I actually dug in, they turned out to differ clearly along three axes.\n\n| Item | MCP | WebMCP |\n|---|---|---|\n| Target | AI Agents in general | Per the spec, mainly browser-embedded Agents |\n| Registration timing | Once, at Agent startup | Every time the page is opened |\n| Session lifetime | Until the Agent is terminated | Only while the page is open |\n| Execution location | Local or a service server | Inside that browser, on that page |\n\nThe difference I felt most viscerally was the session lifetime.\n\n``` php\nstateDiagram-v2\n    [*] --> Unavailable\n    Unavailable --> Available: Open the tab\n    Available --> Unavailable: Close/leave the tab\n    Unavailable --> Available: Return to the tab\n```\n\nAn MCP tool can be called as long as the Agent is up and running.\n\nBut a WebMCP tool is completely tied to \"whether that page is currently open.\"\n\nThe moment you switch tabs, that tool disappears from view; go back, and it reappears.\n\nThe idea that \"the tab's lifetime = the tool's lifetime\" felt pretty fresh to someone coming from an MCP-only mindset.\n\nAs of this writing (August 2026), WebMCP is still a draft at the proposal stage.\n\nEven Chrome's own docs introduce it as an \"upcoming feature.\"\n\n[https://developer.chrome.com/docs/ai/webmcp?hl=ja](https://developer.chrome.com/docs/ai/webmcp?hl=ja)\n\n`chrome://flags/#enable-webmcp-testing`\n\nIn other words, you can't assume \"the native implementation is enabled in every attendee's browser at the hands-on venue.\"\n\nThis is where the thing I built comes in.\n\nThere was a genuine gap between the hands-on requirements and the WebMCP spec if you approached it head-on.\n\nSo, I decided to build something to bridge the two.\n\nWith that in mind, I built a Chrome Extension and an MCP Server as a set.\n\nThere's one thing I was particular about here:\n\n**Only detect and execute the APIs defined by the WebMCP spec ( document.modelContext / annotated <form>s) as-is.**\n\nI didn't want to override the spec with some custom protocol of my own.\n\nThe 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.\n\nHere's the overall structure:\n\n```\nflowchart LR\n    Agent[\"AI Agent<br>(e.g. Antigravity CLI)\"]\n    MCP[\"webmcp-bridge-mcp<br>(MCP Server)\"]\n    Ext[\"webmcp-bridge-extension<br>(Chrome Extension)\"]\n    Page[\"Web page<br>(WebMCP-enabled)\"]\n    Agent <-->|stdio, MCP| MCP\n    MCP <-->|WebSocket| Ext\n    Ext <-->|content/injected script| Page\n```\n\nThe MCP Server (`webmcp-bridge-mcp`\n\n) never touches the DOM directly itself.\n\nIt sticks strictly to being a Bridge / Registry / Router between itself and the Extension, and leaves all DOM manipulation to the Extension side.\n\nIt's a fairly unglamorous division of labor, but the responsibilities are clear, and I had few doubts while implementing it.\n\nThe WebSocket binds to `ws://127.0.0.1:58787`\n\n.\n\nI used 58787 instead of 8787 because 8787 collided with the default port for `wrangler dev`\n\n(Cloudflare Workers).\n\nWhen 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.\n\nThe Extension side is MV3-based and has a two-layer structure.\n\n```\nflowchart TB\n    subgraph Page[\"Inside the web page\"]\n        direction TB\n        Injected[\"injected.ts<br>(main world)\"]\n        Content[\"content.ts<br>(isolated world)\"]\n        Injected <-->|postMessage| Content\n    end\n    Background[\"background.ts<br>(Service Worker)\"]\n    WS[\"MCP Server\"]\n    Content <-->|chrome.runtime| Background\n    Background <-->|WebSocket| WS\n```\n\nThe reason `injected.ts`\n\nneeds to run in the main world is that accessing the page's `document.modelContext`\n\nrequires running in the main world.\n\nYou can't touch it directly from the isolated world (a normal content script).\n\nWhen `document.modelContext`\n\nisn't yet natively implemented in the browser, `injected.ts`\n\nprovides a minimal polyfill for `registerTool`\n\n/ `getTools`\n\n/ `executeTool`\n\n/ the `toolchange`\n\nevent.\n\nIf a native implementation exists, it does nothing.\n\nBy 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.\n\nThere are six tools visible to the Agent:\n\n| Tool | Description |\n|---|---|\n`webmcp_get_status` |\nReturns the Extension's connection state, number of known tabs, and the active tab ID |\n`webmcp_list_tabs` |\nReturns the list of WebMCP-enabled tabs the Extension has captured |\n`webmcp_discover_tools` |\nDiscovers the WebMCP tools on a given tab |\n`webmcp_call_tool` |\nExecutes a tool on a given tab |\n`webmcp_submit_tool` |\nConfirms a submission that's waiting on a human, from the Agent side |\n`webmcp_ping` |\nChecks connectivity with the Extension |\n\nHere's what the input/output look like:\n\n```\n// webmcp_discover_tools input\n{ \"tabId\": 123, \"forceRefresh\": true }\n// output\n{ \"tabId\": 123, \"tools\": [ { \"id\": \"reserve_hotel\", \"name\": \"reserve_hotel\", \"source\": \"imperative\" } ] }\n// webmcp_call_tool input\n{ \"toolId\": \"reserve_hotel\", \"args\": { \"city\": \"Osaka\" } }\n// output\n{ \"ok\": true, \"result\": { \"ok\": true, \"city\": \"Osaka\", \"confirmationId\": \"RES-12345\" } }\n```\n\n`webmcp_submit_tool`\n\nis a bit unusual.\n\nPer the spec, a declarative form without `toolautosubmit`\n\nis expected to stop by focusing the submit button, so that a human reviews the content and submits manually.\n\nThis is a safety mechanism intentionally built into the WebMCP spec.\n\n`webmcp_submit_tool`\n\nis a tool for explicitly overriding that from the Agent side.\n\nI'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**.\n\nThis is the part I most wanted to write about in this article.\n\nThings I never would have noticed just from reading the docs kept popping up once I actually ran it on Chrome for Testing.\n\n`getTools()`\n\nturned out to be async\nFrom skimming the summary in Chrome's developer docs, my impression was that it was a synchronous function.\n\nBut when I checked on real hardware (Chrome for Testing 150), `document.modelContext.getTools()`\n\nreturned a `Promise<ModelContextTool[]>`\n\n.\n\nOn top of that, `executeTool()`\n\ndoesn't take a tool-name string — it requires the actual tool object obtained from `getTools()`\n\n.\n\nPass it a string, and you get a `TypeError`\n\n.\n\nThis was a spot where implementing based only on a summary of the docs would trip you up, plain and simple.\n\n`file://`\n\n, the handshake never finishes, for some reason\nFor `postMessage`\n\nbetween `content.ts`\n\nand `injected.ts`\n\n, I was using `window.location.origin`\n\nas `targetOrigin`\n\n.\n\nOn a page opened via `file://`\n\n, this becomes, for some reason, the literal string `\"null\"`\n\n.\n\nAs a result, the handshake never completed at all, and the overlay would never show up.\n\nI'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.\n\nSo I changed `targetOrigin`\n\nto `\"*\"`\n\n, and instead guarantee legitimacy via a random channel ID.\n\nThis was the type of bug you only notice by trying it with `file://`\n\n; if I hadn't verified the sample page by opening it directly via `file://`\n\n, I probably would have missed it.\n\nWhen a native implementation is present, the browser itself may automatically register declarative forms.\n\nIn that case, this Extension's own `registerTool()`\n\ncall fails as a \"duplicate,\" but I'm treating that as expected behavior.\n\nSince `findAnnotatedFormByName()`\n\nlooks directly at the DOM to determine `source: \"declarative\"`\n\n, it can report correctly regardless of who registered it.\n\nThere were also cases where the natively-synthesized `inputSchema`\n\nreturned empty (`{ type: \"object\", properties: {} }`\n\n) on this particular build.\n\nThis is probably down to the state of the browser's implementation, not a bug in the Extension.\n\nThe WebMCP spec's `execute`\n\nis originally meant to return \"a string summary for the agent.\"\n\nBecause of that, I confirmed on real hardware that even when the page returns an object like `{ ok: true, city }`\n\n, the browser's native implementation JSON-stringifies it before returning.\n\nNeither the Extension nor the MCP Server touch `result`\n\n— they pass it straight through.\n\nSo the MCP client side needs to determine whether it got a string or structured data.\n\nAn MV3 Service Worker gets suspended when it goes idle.\n\nDuring that time, the WebSocket connection also drops.\n\nIt wakes back up automatically when a `chrome.tabs`\n\nevent or similar fires, and the reconnection logic kicks in.\n\nBut if nothing happens right after startup — no tab activity at all — it can stay stuck at `extensionConnected: false`\n\nfor a while.\n\nIf `webmcp_get_status`\n\n/ `webmcp_ping`\n\nreturn `false`\n\n, doing something with the target tab (switching to it, reloading it, etc.) will bring it back.\n\nOn 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`\n\nfinished, leaving the overlay permanently hidden.\n\n`injected.ts`\n\nregisters `<form toolname tooldescription>`\n\nelements as soon as it finds them via `MutationObserver`\n\n.\n\nSo this race is more likely to occur on lightweight declarative-only pages that don't run any imperative script.\n\nThe previous implementation recorded \"the manifest was sent / attempted to be sent\" before the handshake had actually completed.\n\nAs a result, once the handshake did complete, a resend request would be misjudged as \"no diff\" and silently swallowed.\n\nI fixed `reportManifestIfChanged()`\n\nso that it doesn't record or send anything at all until the channel is established.\n\nI 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.\n\nMore 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.\"\n\nPer the spec, a form without `toolautosubmit`\n\nstops by just focusing the submit button.\n\nThis is meant so a human reviews the content and submits manually.\n\nAt least some native implementations keep `executeTool()`\n\nitself blocked during this \"waiting for a human\" period.\n\nThis is not the same behavior as this Extension's polyfill, which immediately returns `{ pending: true, ... }`\n\n.\n\nIf you call `webmcp_call_tool`\n\non a tool without `toolautosubmit`\n\nin an automated environment with no human interaction, you won't get a response back until it times out.\n\nBefore calling it, you need to check whether `webmcp_discover_tools`\n\n's result has `requiresUserGesture: true`\n\n.\n\nI verified the following four scenarios:\n\n`<form>`\n\n)`registerTool()`\n\ncalls)`unlock`\n\nmakes a new tool dynamically appear, and `lock`\n\nmakes it disappear)I actually loaded the Extension on Chrome for Testing and confirmed all patterns passed.\n\nThe fourth one also serves as verification of dynamic detection via the `toolchange`\n\nevent and `MutationObserver`\n\n.\n\nSince `webmcp_discover_tools`\n\nreturns a cached result by default, you need `forceRefresh: true`\n\nto observe these dynamic changes.\n\nI'm using Chrome for Testing (the Chromium bundled with Playwright) because, from Chrome 137 onward, official Google Chrome builds have removed the `--load-extension`\n\nflag for automation purposes.\n\nLoading an extension manually via `chrome://extensions`\n\nworks fine with regular Chrome.\n\nBut if you want to load an extension in automated tests, you need Chrome for Testing or Chromium.\n\nOn the MCP Server side, I use a mock class called `FakeExtension`\n\n.\n\nIt 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`\n\nbehavior, concurrent tool call execution, and cleanup on disconnect.\n\nSince 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.\n\n`postMessage`\n\nbetween the main world (`injected.ts`\n\n) and the isolated world (`content.ts`\n\n), 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`\n\n, 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.\n\nI stopped at the line of \"good enough to run at the hands-on session\" and haven't designed authorization with production use in mind.\n\nEven though I put this together somewhat forcefully, there are benefits I genuinely felt after actually using it.\n\nFrom here on, this is entirely my personal opinion.\n\nHonestly, having implemented it and used it at the hands-on session, what I felt most strongly was, \"do we really need this?\"\n\nThere are a few reasons.\n\nFirst, the motivation to use a browser-embedded Agent is still weak.\n\nActually, I myself didn't even know Chrome had a built-in Agent until I did this investigation.\n\nI imagine there are quite a few people in the same boat.\n\nAnd WebMCP has the constraint that it only works while the page is open.\n\nThat's subtly restrictive.\n\nHaving a tool disappear the moment you switch tabs felt a bit inconvenient, coming from the mindset I'm used to with MCP.\n\nIn 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.\n\nFor 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.\n\nAnd honestly, from a layperson's perspective, I also wondered if the target audience selection itself might be a bit off.\n\nThe benefit itself — \"fewer mistaken operations from the Agent\" — is appealing.\n\nBut 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.\"\n\nThat 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.\n\nI 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.\n\nGiven how harsh some of this has been, you might wonder why I'm publishing it at all.\n\nThe reason is simple.\n\nIt 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.\n\nEven 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.\n\nAlso, if people end up using the extension or the MCP server, that makes me happy.\n\nWebMCP is, directionally, an interesting spec: \"the web page itself declares tools for an Agent.\"\n\nThat said, as of August 2026 it's still at the draft stage, and its target is limited to browser-embedded Agents.\n\nSo 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.\n\nOn 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.\n\nHere's what I built and the related links:\n\nFeel free to check out the repos and reach out with any questions.", "url": "https://wpnews.pro/news/i-built-a-bridge-for-google-s-new-webmcp-draft-spec-here-s-what-broke", "canonical_source": "https://dev.to/tanahiro2010/i-built-a-bridge-for-googles-new-webmcp-draft-spec-heres-what-broke-1691", "published_at": "2026-08-03 10:22:51+00:00", "updated_at": "2026-08-03 10:45:28.520302+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "developer-tools"], "entities": ["tanahiro2010", "GDG Greater Kwansai", "Google", "WebMCP", "W3C Web Machine Learning Community Group", "Antigravity CLI", "MCP"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-bridge-for-google-s-new-webmcp-draft-spec-here-s-what-broke", "markdown": "https://wpnews.pro/news/i-built-a-bridge-for-google-s-new-webmcp-draft-spec-here-s-what-broke.md", "text": "https://wpnews.pro/news/i-built-a-bridge-for-google-s-new-webmcp-draft-spec-here-s-what-broke.txt", "jsonld": "https://wpnews.pro/news/i-built-a-bridge-for-google-s-new-webmcp-draft-spec-here-s-what-broke.jsonld"}}