WebMCP
Your components' tools, registered with the user's own browser agent. Poetry components declare what an agent may do to them; a rendered instance opts in with one keyword; the browser does the rest.
What it is
WebMCP is the
browser standard behind document.modelContext: a page
registers typed tools - a name, a description, a JSON Schema, and a
function - and the agent built into the browser calls them instead of
scraping the DOM and guessing at clicks. It is in origin trial in
Chrome 149 and Edge 150, and ChatGPT Desktop's browser supports it.
Poetry treats it as one more projection of the component contract.
Each interactive component declares its tools in Ruby beside its
Stimulus wiring; the registry carries them, so the
MCP server and llms.txt describe them to
coding agents too; and at render time a single keyword,
webmcp:, turns a rendered instance into a registered
tool set for the person's agent. Nothing registers until you say so.
Live: the tools on this page
These three components opted in. In a browser with WebMCP enabled
the panel below lists what they registered and lets you call a tool
by hand - the same getTools() and
executeTool() calls an agent makes.
Ask the agent for the pricing tab - or call poetry.sections.set_value with {"value":"pricing"} below.
The tabs component declared set_value; the rendered tab values became the parameter's enum.
Every tool dispatches to the component's own Stimulus action - the same code a click runs.
Checking this browser for document.modelContext…
Search the docs: a declarative tool
Forms need no JavaScript at all. This one carries
toolname and tooldescription; the browser
synthesizes the parameter schema from the control and its label, and
because it is a GET form it may autosubmit - an agent
asked to "find the combobox docs" fills the field and submits.
Two paths
Interactive components take the imperative path: opt an instance in
and its declared tools register as
poetry.{name}.{tool}. Give instances explicit names
when a page renders several of one component - the browser rejects
duplicate tool names.
<%= poetry_tabs(label: "Sections", webmcp: "sections") do |tabs| %>
<% tabs.with_tab("Overview", value: "overview") { "..." } %>
<% tabs.with_tab("Pricing", value: "pricing") { "..." } %>
<% end %>
<%= poetry_dialog(webmcp: "settings") do |dialog| %>
<% dialog.with_trigger { "Settings" } %>
<% dialog.with_title { "Settings" } %>
<% end %>
<%= poetry_combobox(name: "country", webmcp: "country") do |combobox| %>
<% combobox.with_item(value: "de") { "Germany" } %>
<% end %>
Forms take the declarative path. poetry_webmcp_form
defaults to the Poetry FormBuilder, so model-derived labels describe
the parameters for free; tool_description: overrides
one. autosubmit is allowed only on GET forms - a
mutating form always keeps the person's Submit.
<%= poetry_webmcp_form(url: search_path, method: :get,
tool: { name: "search_docs",
description: "Search the docs by title or description.",
autosubmit: true }) do |form| %>
<%= form.field(:q, tool_description: "Words to search for.") %>
<% end %>
One honest limit of the declarative path: a form tool carries no
annotations today (the attribute set is four, and annotations for
forms are an open spec issue), so an audit that reads
readOnlyHint cannot tell a search form from a
mutation. The GET-only autosubmit rule is the
guarantee it cannot see; where the annotation itself matters, a
component tool carries it.
Designing tools for your app
Component tools are the operate layer: what an agent may do to a rendered combobox, dialog, or tab strip. The tools that make a journey - search, hold, book - are yours to declare, and Chrome's framework for choosing them is short. Define the user's goal and what success looks like. Define the initial state: where the person is, what the agent already knows. Role-play the conversation turn by turn and note which tool each turn needs. Let the agent ask when a request is vague instead of guessing. Make every failure a guide ("No results yet - search first"), never a dead end.
- Answer tools read: search, details, availability. Ship these first; they keep an agent from guessing about your data. A GET
poetry_webmcp_formis one. - Action tools change state without commitment: filter, add to cart, open a panel. Component tools live here; they are declared
mutating: true. - Sensitive actions involve money or commitment. Keep the person's Submit: a form without
autosubmitis the reversible boundary.
One function per tool and no overlap; names that say what happens
(add_to_cart acts, start_checkout opens
a flow); descriptions that say what comes back. Then evaluate: the
smoke suite below is deterministic, and an LLM-driven eval of tool
choice is the layer above it.
Declaring tools in your own components
tool sits beside use_stimulus. The
executes: action is validated against the controllers
manifest at class load, so a tool can never name an action the
JavaScript does not define; parameters map positionally onto the
action's arguments in declared order. A subclass that re-controllers
its root (Sheet over Dialog) projects its own controller's action.
class Tabs::Component < Poetry::Core::Component
use_stimulus do
on(:root) { controller(:tabs) { register } }
end
tool :set_value,
description: "Activate the tab whose value matches and show its panel.",
params: { value: { type: "string", required: true, description: "The tab's value." } },
executes: :set_value, # validated against the controller at class load
mutating: true
# Optional: refine the projected schema with what the rendered
# instance knows - here the rendered tab values become the enum.
def webmcp_tool_definition(definition)
return definition unless definition["name"] == "set_value"
values = tab_defs.map(&:value)
schema = definition.fetch("inputSchema")
value = schema.fetch("properties").fetch("value").merge("enum" => values)
definition.merge("inputSchema" => schema.merge("properties" => schema["properties"].merge("value" => value)))
end
end
Setup
gem "poetry-agent"
// app/javascript/controllers/index.js
import { registerPoetryControllers } from "@poetry/controllers"
import { registerPoetryAgent } from "@poetry/agent"
registerPoetryControllers(application)
registerPoetryAgent(application)
Locally, enable the API in Chrome at
chrome://flags/#enable-webmcp-testing; Chrome's Model
Context Tool Inspector extension shows registrations and calls them.
For real visitors during the origin trials, register your origin and
hand the tokens to the middleware the engine mounts:
# config/initializers/poetry_agent.rb
Poetry::Agent.configure do |config|
config.origin_trial_tokens = ENV.fetch("WEBMCP_ORIGIN_TRIAL_TOKENS", "").split(",")
config.registration_budget = 20 # tools per document; extras are dropped with a warning
end
Two constraints of the host page. The API needs an origin-isolated
document, so never send Origin-Agent-Cluster: ?0 or
set document.domain. And imperative tools register
when the importmap module graph runs, a beat after the parser has
registered the declarative forms, so a scanner that snapshots at
parse time sees only the forms - serve the runtime preloaded and
cached. And never parse HTML that carries the four tool attributes
into an inert document (DOMParser,
createHTMLDocument): Chrome 151 crashes the renderer
on such a form, which is why the form runtime strips them from a
fetched answer before reading it. Turbo's own visits are
unaffected.
Tooling: Chrome's Model Context Tool Inspector and the nekuda
WebMCP Workbench list registrations and call them by hand, and
Google's webmcp-evals runs suites against a live page.
This site keeps one: bin/rails webmcp:smoke executes
every tool on this page in a real Chrome with no model involved.
Safety by construction
- Off by default: declaring a tool exposes nothing; a rendered instance opts in.
- Read-only by default: a tool is
mutating: trueonly when declared so, and the annotation says which. autosubmitis GET-only; a mutating form keeps the person in the loop.- A per-document registration budget; duplicate names are refused by the browser and skipped.
- Re-registration only when the payload changed, never under Turbo's cache preview - the two morph-time hazards the standard documents.
- Every call is validated in code: a missing, unknown, or mistyped parameter, or a value outside the rendered options, comes back as a message naming what the tool takes.
- An answer is the resulting state - the active tab, the committed value, open or closed - and a form's answer is followed by the page catching up, so the person sees what the agent read.
- Errors return as descriptive strings, so an agent corrects its call instead of guessing.