WebMCP adapter¶
portage-ucp-webmcp — WebMCP transport onto the same Adapter contract: inbound
(document.modelContext, serving your Adapter straight into a browser page) and outbound
(driving a different page's WebMCP tools via a browser driver).
portage-ucp-webmcp¶
WebMCP for
portage-ucp, as a
transport, not a commerce backend. WebMCP lets a web page register tools on
document.modelContext for an agent running in the browser to call. This gem
makes that page one more way to reach the existing Adapter contract, next to
classic MCP (stdio, Streamable HTTP) and native UCP.
┌── stdio / Streamable HTTP ──┐
Agent ── Session ────────┤── native UCP (HTTP) ────────┤── Mcp::Server ── Dispatcher ── Adapter
└── WebMCP (a browser page) ──┘
Two halves, usable separately:
| Half | Side | What it does |
|---|---|---|
| Inbound | Merchant | Registers a Portage-powered store's catalog/cart/checkout tools on its pages via document.modelContext. Each tool call POSTs back to the store, into the same Portage::Ucp::Mcp::Server every other transport uses. |
| Outbound | Agent | A portage-ucp-client transport that finds and calls the WebMCP tools any page registers, Portage-powered or not, through the browser driver you already run. |
The Adapter stays the single source of truth. The tool list comes from the
CapabilityRegistry, as it does for MCP. Nothing commerce-related runs in the
browser.
Inbound: expose your store to browser agents¶
require "portage/ucp/webmcp"
catalog = Portage::Ucp::WebMcp::ToolCatalog.new(
adapter: adapter,
authenticator: MyCookieAuthenticator.new # same Authenticator contract as MCP
)
# config.ru
map("/ucp") { run Portage::Ucp::WebMcp::Rack::App.new(catalog: catalog) }
# Rails
mount Portage::Ucp::WebMcp::Rack::App.new(catalog: catalog), at: "/ucp"
Rack::App serves two routes under its mount point:
| Route | Serves |
|---|---|
GET /webmcp.js |
The page script (Registrar). It registers each tool on document.modelContext, or on navigator.modelContext in earlier-draft browsers. |
POST /webmcp |
Its tool calls (Rack::CallEndpoint). One stateless JSON-RPC tools/call in, one result out, answered by the catalog's Mcp::Server. |
What the page registers¶
ToolCatalog decorates the MCP server's generated tools for browser agents:
- Readable descriptions and typed JSON Schemas.
Mcp::Servercan only name parameters, so every property there is{}. - WebMCP annotations:
readOnlyHintfor reads, andconsequentialHintforcancel_order,refund_order,request_returnandcomplete_checkout. idempotency_keyis optional. The page generates one per mutating call when the agent leaves it out.
What the catalog exposes:
- Capabilities: standard
dev.ucp.shopping.*capabilities only, without identity linking. Tools that take an OAuth token or manage stored credentials (saved payment methods, saved addresses, shopper-data deletion, payment enrollment) are left out. Widen withcapabilities: ->(name) { ... }. complete_checkout: left out (DEFAULT_EXCEPT). Over any server transport it runs the Dispatcher's paymentConfirmer. That defaults toConfirmer::Terminal, which prompts on the server's stdin, andMcp::Server.buildhas no seam to replace it. On a web request that prompt holds the request until it times out and denies. In the browser the shopper is present anyway: the agent builds the checkout, and the shopper pays in the store's own checkout (the checkout'slinksorcontinue_url). Opt in withexcept: []only after you have solved confirmation.- Filters:
only:,except:andprefix:(for exampleprefix: "acme."when the page registers WebMCP tools of its own).
Rack::CallEndpoint enforces the same filter on the server side. An action the
page doesn't register can't be reached by POSTing its name.
A second GET /webmcp.js (a Turbo/SPA reload re-running the script tag)
re-registers cleanly: it waits for the previous generation's own in-flight
registerTool calls to settle and unregister before registering the same
names again, bounded by registrar_options: { reregister_wait_ms: } (default
2000) so a previous generation that never settles can't block the page
forever.
Security¶
The endpoint is browser-facing and the page's cookies go with every call. For that reason it is stricter than a plain MCP endpoint:
- JSON bodies only. A cross-site
<form>can't reach the endpoint without a CORS preflight. Originrequired. The header must matchallowed_origins. The default is the endpoint's own origin. A request with noOriginis refused unless you passrequire_origin: false.- Two methods only.
tools/callandtools/list, and only for exposed actions. - Same guards as MCP. Mutating calls go through your
AuthenticatorandRateLimiter, exactly as over MCP. Theserver_contextthey receive carriestransport: "webmcp",request:(theRack::Request, for your session cookie or a CSRF header) andorigin:.
Put a CSRF token on the page's requests with
registrar_options: { headers: { "x-csrf-token" => token } } and check it in
your Authenticator.
For a storefront on another origin than the endpoint, use this pattern:
Portage::Ucp::WebMcp::Rack::App.new(
catalog: catalog,
call_options: { allowed_origins: ["https://shop.example"] },
registrar_options: { endpoint: "https://api.shop.example/ucp/webmcp", credentials: "include" }
)
The endpoint then answers the CORS preflight and sends credentialed CORS headers for that origin only.
Request limits¶
Two more limits bound a single POST /webmcp, on top of the Origin/method/
content-type checks above. Every rejection — these two, the checks above, and
a JSON-RPC error dispatch itself returns — answers with the same JSON-RPC
error envelope, so a caller never needs to branch on HTTP status to read why
a call failed.
max_body_bytes:(default 1MiB). AContent-Lengthover the cap is refused with413before anything is read. Otherwise at mostmax_body_bytes + 1bytes are ever read off the request body, so a body with noContent-Length(or one that under-reports) still can't be buffered in full first.call_timeout:(default 30 seconds,nildisables it). Bounds onetools/call/tools/listdispatch into the catalog'sMcp::Server#handle. A call that runs past it answers a JSON-RPC error rather than holding the browser'sfetch(and the request thread) open indefinitely. This is a floor under the endpoint, not a replacement for your ownAdapter/HTTP client timeouts — a slow adapter call still ties up the thread until it fires.
Portage::Ucp::WebMcp::Rack::App.new(
catalog: catalog,
call_options: { max_body_bytes: 262_144, call_timeout: 10 }
)
Content-Security-Policy¶
The registrar's script and its fetch calls need two directives, if your
store sets a CSP:
script-src: allow the originGET /webmcp.jsis served from (usually your own,'self'). The script has no inline<script>body — it's asrc=include — so no'unsafe-inline'or nonce is needed for it.connect-src: allow the originPOST /webmcptargets. For a same-origin endpoint,'self'already covers it. For the cross-origin pattern above (registrar_options: { endpoint: "https://api.shop.example/..." }), add that origin explicitly:connect-src 'self' https://api.shop.example. Also add it for every origin passed toexposed_to:if that agent surface itself calls the endpoint from a different frame's document.
Nothing here needs 'unsafe-eval': the registrar only calls fetch and
document.modelContext.registerTool, never eval or new Function.
CSRF posture¶
allowed_origins/Origin checking (above) is the endpoint's primary CSRF
defense: a browser attaches the real Origin on every fetch, and page
script can't override it, so a cross-site page's request is rejected before
your Authenticator ever sees it — the same protection SameSite cookies
give a classic form POST, enforced here explicitly since the endpoint has to
work from a cross-origin storefront too (allowed_origins:).
If your Authenticator reads a session cookie (cookie-auth, credentials:
"same-origin" or "include"), also set a CSRF token, the same as you would
for any other cookie-authenticated POST endpoint: put it on the page's
requests with registrar_options: { headers: { "x-csrf-token" => token } }
and check server_context[:request] for it in your Authenticator, exactly
as spec/support/store.rb does in this gem's own specs. Origin checking
alone is not a substitute for this when the endpoint's Authenticator trusts
a cookie: a same-origin Origin only proves the request came from a page on
your site, not that the page is the one your store served (an XSS on another
page of the same origin, or a subdomain sharing the cookie's domain, can
still fetch same-origin).
Browsers without WebMCP¶
When the page has no modelContext, the registrar does nothing. It records
window.portageWebMcp.reason = "no_model_context". Pass
registrar_options: { include_polyfill: true } to install a minimal,
spec-shaped document.modelContext first. That helps agents that drive a
browser build without native WebMCP. A browser that has a native
implementation keeps it.
Outbound: call any page's WebMCP tools from an agent¶
require "portage/ucp/webmcp"
page = Ferrum::Browser.new.create_page
page.command("Page.addScriptToEvaluateOnNewDocument", source: Portage::Ucp::WebMcp.polyfill_js) # optional
page.go_to("https://shop.example/products/mug")
session = Portage::Ucp::WebMcp.connect(
bridge: Portage::Ucp::WebMcp::Bridges::ScriptEvaluator.ferrum(page)
)
session.search_catalog(query: "mug")
session.create_checkout(line_items: [{ product_id: "mug", quantity: 1 }])
session is the same Portage::Ucp::Client::Session that
Client.for_adapter, .connect and .discover return. It has the same
methods, the same client-side PaymentTokenGuard and the same ServerError
on a tool error. The caller doesn't need to know which transport it got.
Browser drivers¶
No driver gem is a dependency. ScriptEvaluator takes any callable that
evaluates a JavaScript expression in the page and returns what its promise
resolves to. Helpers exist for three drivers:
| Driver | Bridge |
|---|---|
| Ferrum | ScriptEvaluator.ferrum(page) |
Playwright (playwright-ruby-client) |
ScriptEvaluator.playwright(page) |
| Selenium WebDriver | ScriptEvaluator.selenium(driver) |
| Anything else | ScriptEvaluator.new(evaluate: ->(js) { ... }), or WebMcp.connect(evaluate: ...) |
A browser extension, a raw CDP session or a remote grid can write its own
bridge instead. Any object with #list_tools and #execute_tool(name, input)
works.
WebMCP surfaces¶
The consumer script tries these in order:
document.modelContext, ornavigator.modelContext, withgetTools()andexecuteTool(). This is the current spec.navigator.modelContextTesting, withlistTools()andexecuteTool(name, json). This is the testing API from early browser builds.listTools()andcallTool({ name, arguments }), the shape that some userland polyfills use.
A page that registers tools but offers none of these to a consumer can still be
read. Inject WebMcp.polyfill_js before navigation so the page registers into
something the consumer can list.
Stores that don't run Portage¶
Transport does not assume that the page was built with this gem. Three
ways to get a working tool_names:/wire: for a page you don't control,
tried in this order (docs/plans/webmcp-universal-outbound.md):
- A built-in preset.
WebMcp::Presets.detect(tools)matches a page's tools against a known platform's exact fingerprint — the whole sorted set of tool names it registers right now, never anything the page's own text says about itself (ageneratormeta tag,window.Shopify, a tool's own description — all untrusted page content, same boundary as the confirm rule below).WebMcp.connect(bridge:, preset: :auto)— the default — runs detection (one extra page read, on top of the onecapabilities: nilalready does) and applies the matching preset'stool_names:/wire:before anything else.preset: nilturns presets off entirely; a symbol (preset: :shopify) forces one with no detection read at all. An explicittool_names:/wire:you also pass toconnectstill wins over the preset's own —tool_names:key by key,wire:outright. Shopify (Presets::SHOPIFY) is the only entry so far; see "Shopify storefronts" below. A platform can change its tool set without notice — when it does,detectsimply misses and the page falls through to the next mechanism rather than being mapped wrongly. - A matched-and-confirmed mapping, for a page no preset recognizes.
WebMcp::Matcher.propose(tools)scores each of the page's own tools against every UCP action Session can drive over WebMCP, by name-token overlap (findProducts↔search_catalog), input-schema shape (aquerystring → search;product_id/variant_id+quantity→ add to cart), and the tool's ownreadOnlyHint— never itsdescription, which is page text and therefore untrusted (a page could name a mutating toolsearch_catalogwith a friendly description; nothing here reads that string for scoring or for the mapping it returns). It returns aProposal(tool:,confidence:,reason:) per action that scored above a floor; an action nothing plausible matched is simply absent rather than given a weak guess.Matcheritself never calls a tool, prompts anyone, or persists anything — that's the caller's job, and the confirm rule it leans on is: a read action (search_catalog,get_product,get_cart) can be used straight off the proposal, since a wrong match there wastes a call and changes nothing; a mutating action (create_cart,update_cart,create_checkout) needs confirmation before its first call, since a wrong guess there could add to, discard, or attempt to charge a stranger's cart.portage-clienforces that rule forportage buy:Portage::Cli::WebmcpMappingConfirmprints the proposed mapping — quoting a mutating tool's owndescriptionas what it is, page content, never as an instruction — and prompts on a real TTY with--jsonoff; under--json, or with no TTY, the run stops instead (outcomewebmcp_mapping_unconfirmed) and returns the proposal intool_names_proposal. Noportage buyflag orBuykeyword takes it back: confirm it interactively, or passtool_names:toWebMcp.connectyourself (seeportage-cli's README).Portage::Cli::WebmcpMappingspersists a confirmed mapping in~/.portage/webmcp_mappings.json, keyed by the page's tool fingerprint (WebMcp::Fingerprint.for— sorted tool names plus a hash of each one's own input schema) rather than by origin: any later store whose WebMCP tools have that exact shape reuses the mapping with no prompt at all, in effect a locally-grown preset shared across every store running that tool set. A lookalike page whose schemas differ even slightly gets a different fingerprint and has to be confirmed again, so it can't borrow a mapping that turns out to have a different shape underneath the same tool names. -
An explicit
tool_names:you pass yourself. Always available, preset or confirmed mapping or not — see the next bullet. -
Tool names. An action resolves to
tool_names[action]first, then to"#{prefix}#{action}", then to the action itself. Map a store's own names:WebMcp.connect(bridge:, tool_names: { search_catalog: "findProducts" }). A miss reads the page again once (for tools registered late), then raisesToolNotFoundError, which lists what the page does register. - Argument shape. The shape comes from each tool's own
inputSchema. If its properties nest undercatalog,cartorcheckout, or it takesidandmeta, it is a real-UCP-shaped tool. It gets the same body thatTransports::Httpbuilds for native UCP (the code is shared throughTransports::UcpWireShape). Other tools get Session's flat arguments, as over stdio. Force one shape withwire: :ucporwire: :flat. - Results. An MCP-style
CallToolResultis unwrapped the same way as over stdio or HTTP, andisErrorraisesServerError. A JSON string (the spec'sexecuteToolresolves to one) is parsed. Anything else is returned as it is. - Re-registration. Some pages drop all their tools and register them again
while they re-render. A call to a tool that vanished that way waits up to
reregister_wait:(default 5 seconds) for it to come back, then retries. The call never ran the first time, so the retry is safe. The same wait covers a tool that is registered but answers that the page isn't ready (PageWait::NOT_READY: Shopify's "Standard Actions are not available", which its storefronts answer while they reload after their ownadd_to_cartorcancel_cart). No other tool error is retried. The wait blocks the calling thread; see "Timeouts" below.
Timeouts¶
Two limits bound an outbound call, and both block the thread that made it:
- The browser driver's own timeout bounds each round trip to the page.
ScriptEvaluator.ferrum(page, timeout: 30)passes itstimeout:(default 30 seconds) toevaluate_async. Playwright and Selenium use the page's or driver's own script timeout, so set it there. A driver that times out raises, and the error surfaces asBridgeError. reregister_wait:(default 5 seconds,WebMcp.connect(..., reregister_wait:)) bounds the wait for a tool the page dropped mid-call, or one that answered "not ready". It's a plainsleep, polling every 0.25 seconds. One monotonic deadline covers the whole call: it's set before the first attempt, a retry never resets it, the retried calls count against it, and no sleep runs past it. A miss costs at mostreregister_wait:plus the one driver round trip in flight when it expires. Passreregister_wait: 0to fail on the first miss, or make the call off a thread that can't afford to block.
Shopify storefronts¶
Checked live on 2026-09-28: 7 of 9 Shopify storefronts tried (ColourPop,
tentree, Kylie Cosmetics, Brooklinen, Allbirds, Billabong, The Light Yard)
register the same 11 WebMCP tools of their own, with identical schemas, and
preset: :auto detects all of them as :shopify. Gymshark and Fashion Nova
registered none. The tools take UCP-shaped arguments, so wire: :auto picks
the UCP shape. Two names differ from Session's:
session = Portage::Ucp::WebMcp.connect(bridge: bridge, tool_names: { create_cart: "add_to_cart" })
session.search_catalog(query: "hoodie") # page tool: search_catalog
session.get_product(product_id: product["id"]) # page tool: get_product, variants included
session.create_cart(line_items: [{ product_id: variant["id"], quantity: 1 }]) # adds to the browser's cart
session.get_cart(cart_id: "current") # the browser's cart; cart_id is ignored
Things to know about these tools:
- The cart is the browser session's own cart, so
add_to_cartreturns no cart id and adds to what is already there. - Search results carry no variants. Call
get_productfor variant ids. - Right after
add_to_cart, the page's cart tools can fail for a second or two withStandard Actions are not available ... Try again. Wait and call again. update_cart_linesaddresses existing cart lines by line id, not by variant.proceed_to_checkoutnavigates the browser to Shopify checkout, where the shopper pays. Neither maps onto a Session method.
Approved autofill of the store's checkout¶
Once a WebMCP session hands off to the store's own checkout — a preset's
handoff_checkout tool (Shopify's proceed_to_checkout) navigates the same
bridge's browser there — the shopper can opt into having contact and
shipping fields filled in for them before they take over to pay. This is
off by default and gated behind two separate approvals, neither of which
lives in this gem:
- The opt-in itself is
portage-cli's:portage buy --autofill, orPORTAGE_WEBMCP_AUTOFILL=approve(config.json's"webmcp_autofill": "approve"also works) for every run, until the caller opts out again. Only the literal string"approve"turns the env/config level on — an unrelated truthy convention likePORTAGE_WEBMCP_AUTOFILL=truedoes nothing, deliberately, since a mistaken export of some other tool's boolean shouldn't silently start typing into a checkout page. - The shopper's approval of the exact fields. Even opted in, nothing is
typed until a prompt lists every field and value about to be entered and
the shopper says yes — refused outright under
--jsonor with no TTY on stdin, the same posture as the mapping-confirm prompt above.
What it will fill, once both approvals are in: the contact email and
shipping address portage-cli already builds from PORTAGE_SHIP_* (plus
the new PORTAGE_SHIP_EMAIL), matched to the checkout page's own fields by
their WHATWG autocomplete attribute (email, shipping given-name,
shipping address-line1, shipping postal-code, …), and the cheapest
priced option in a same-named radio-button group it can read a price out
of (a best-effort DOM heuristic, unverified against a real rate picker — see
the Progress log in the plan). What it will never do, regardless of what
it's asked for: touch a field whose own autocomplete is payment-shaped
(cc-*, transaction-*) or whose type is hidden/password — checked
against the page's own attribute, not against what was requested, so a
mislabeled card field still can't be reached — or click a submit/pay
control. The run always still ends in a hand-off; autofill only changes
what's already filled in when the shopper gets there.
WebMcp::Autofill.call(bridge:, fields:, selectors:) is the gate that
actually runs it, and the outcomes a caller (or a report's autofill: key)
can see are:
| Outcome | Meaning |
|---|---|
:filled |
Ran. #filled/#unmatched list which fields matched a page element and which didn't; #rate lists any shipping-rate group it picked from. |
:needs_headed_browser |
The bridge is headless, or never says either way. A bridge's #headless? defaults to unknown, and unknown is treated the same as headless — the shopper has to be able to see and pay in this browser, so "assume headed" isn't the safe default. |
:blocked |
The checkout page itself showed a CAPTCHA/challenge marker (an embedded reCAPTCHA/hCaptcha/Turnstile iframe, a Cloudflare interstitial, a matching page title). Nothing was touched, and nothing here ever tries to solve or route around it. |
:unsupported |
The bridge doesn't implement #autofill at all — a hand-rolled Bridge that only meets the base #list_tools/#execute_tool contract. |
The DOM work — finding fields, refusing payment ones, detecting a
challenge, filling through each element's native value setter so a
React/Vue-controlled input actually notices — lives entirely in
assets/autofill.js, run through Bridges::ScriptEvaluator#autofill, never
as a WebMCP tool call. It treats the checkout page as untrusted content the
same way the matcher above treats a tool's description: it never follows
a link or acts on any instruction the page's own text might contain, and
only ever reads autocomplete/type attributes and radio-group prices,
sets a field's .value, or checks a radio button already on the page.
A field the standard autocomplete match misses falls back to
Presets::Preset#checkout_selectors — a platform's own CSS selectors,
keyed by the same autocomplete token, for a checkout whose markup doesn't
carry one. Shopify's has two, both taken from a live checkout on 2026-09-28:
its email field is autocomplete="shipping email" (not email) and its phone
field is shipping tel-national (not shipping tel). Every other contact and
shipping field matched by its own autocomplete value.
Errors¶
All errors are under Portage::Ucp::Client::Error, so existing rescue blocks
still catch them.
| Error | When |
|---|---|
WebMcp::BridgeError |
The page has no WebMCP surface, the driver failed, or the result couldn't be read. It quotes at most 300 characters of the driver's own error. |
WebMcp::ToolNotFoundError |
No page tool answers the action. #available lists what the page registers. |
Client::ServerError |
The tool ran and failed: isError, the page's tool threw, or the endpoint's own call_timeout: fired. |
Development¶
The browser-side scripts are plain .js files under
lib/portage/ucp/webmcp/assets/. Specs tagged :node run them unmodified in
a node process that stands in for a browser tab
(spec/support/node_browser.{js,rb}). The fetches of that tab are answered by
the real Rack::App. This makes the round-trip specs go page, then endpoint,
then Mcp::Server, then ReferenceAdapter, with nothing mocked. They also
check that WebMCP returns the same documents as the in-process Loopback
transport. These specs are skipped when node isn't installed.
spec/portage/ucp/webmcp/real_browser_spec.rb runs the same register ->
call -> re-register sequence against a real, headless Chrome (via ferrum)
instead of the node stand-in, for a second confirmation against an actual
browser's WebMCP/fetch behavior. It's slow and needs Chrome or Chromium
installed, so it's excluded by default: