WebMCP turns a plain JavaScript function into an agent-callable tool with one call: navigator.modelContext.registerTool. No SDK, no imports, no server. You feature-detect the API, register a say_hello tool, and the same greeting a human triggers with a button is now a tool an agent can call. This page below is the lab. It is running the exact code you are about to read.
WebMCP is early-preview. Most browsers do not have it. The first rule is: never assume navigator.modelContext exists. Check for it, and keep the human path working either way. This is the single trap that breaks the naive tutorial.
if (navigator.modelContext && typeof navigator.modelContext.registerTool === 'function') {
// WebMCP is present. Register your tools.
} else {
// Not present. The page must still work for humans.
// Show a banner: enable chrome://flags/#enable-webmcp-testing
}
Drop this in a <script> tag at the bottom of any HTML page. greet() is a normal function that updates the DOM. The button calls it. The tool's execute calls the same function and returns a result the agent can read. One function, two callers.
// The shared function. A human button calls it; the agent tool calls it too.
function greet(name) {
const text = "Hello, " + (name || "world") + ".";
document.getElementById("greeting").textContent = text;
return text;
}
// Register the tool ONLY when WebMCP is present. Always feature-detect first.
if (navigator.modelContext && typeof navigator.modelContext.registerTool === "function") {
navigator.modelContext.registerTool({
name: "say_hello",
description: "Greet a person by name and show the greeting on the page.",
inputSchema: {
type: "object",
properties: { name: { type: "string", description: "The name to greet." } },
required: ["name"]
},
execute: async (args) => {
const text = greet(args && args.name);
return { content: [{ type: "text", text: text }] };
}
});
}
navigator.modelContext guard means this page loads fine in every browser. Only Canary-with-the-flag registers the tool.name. Make it a verb an agent would search for: say_hello, not handler.required tells it which are mandatory.greet(), and returns an MCP result: { content: [{ type: "text", text }] }. That text is what the agent reads back.Type a name and click. This is the ordinary page. No agent involved. Watch the activity log record the call with a human source tag.
Two ways to reach the agent path. If you installed the WebMCP Tool Inspector extension, open it on this page: say_hello appears in the tool list. Fire it with { "name": "Ada" } and watch the greeting change with an agent tag in the log. No extension yet? The Simulate agent call button above runs the tool's own execute locally, so you can see the agent path before Canary is set up.
Or prove registration straight from the console, no extension required:
// Is the API here at all? 'modelContext' in navigator // true in Canary with the flag // Register succeeded without throwing? Then the agent sees say_hello. // The Tool Inspector extension is how you invoke it by name from outside.
{ "content": [ { "type": "text", "text": "Hello, Ada." } ] }
You registered a tool with fifteen lines of vanilla JavaScript and no agent SDK on the page. The browser now exposes say_hello to any agent connected through navigator.modelContext. The agent calls it inside your page, in the human's own session, with no separate auth and no scraping. Same DOM, two callers. That is the entire premise of WebMCP, and you just shipped it.
Skipping the feature-detect. The naive version calls navigator.modelContext.registerTool at the top of the script. In any browser without WebMCP, that throws a TypeError, the script dies, and your human page breaks for everyone. The navigator.modelContext guard is not optional politeness. It is the difference between progressive enhancement and a page that only works in one preview build of one browser.