Home / Articles

WebMCP in Practice: Turning a Task Board Into Tools for AI Agents

WebMCP in practice: a task board turned into tools for AI agents with declarative and scripted tools, a changing tool list, an MCP bridge and a refusal.

Publisher: REACTAPP.TOP14 min read3318 words

This article is published in English.

Sources: WebMCP: When Websites Become AI Tools

Agents that guess at your website

Today an AI agent working with a website is mostly guessing. It grabs a screenshot and looks for something that resembles a button, or it parses the DOM and hopes the class names mean what they appear to mean. After a click it checks whether the page changed in a way that suggests success. That's an impressive trick resting on a weak foundation, since the site never told the agent anything and every conclusion was inferred.

WebMCP is Chrome's proposal for ending that guesswork. A page announces its capabilities, the agent reads the announcement and invokes a function with structured arguments, rather than aiming at a rectangle on screen. The change looks minor but relocates the intelligence: instead of rebuilding an application's purpose from pixels and markup, the agent gets that purpose handed over by the application itself.

To check whether this holds up in practice, a small working task board was built and a real agent was pointed at it. The most instructive result came when the agent flatly refused a request.

Three roles

Every moment in the experiment involves one of three actors.

  • The page is the provider. It registers tools. Each tool pairs a name with a description aimed at a model, a JSON Schema describing the arguments, and a function that does the work.
  • The browser is the registry. For each tab it holds the list of tools, derives schemas from your HTML wherever possible, and decides when a tool shows up or goes away.
  • The agent is the consumer. It queries the browser for what's on offer with getTools() and runs a chosen tool with executeTool(). The DOM stays out of its reach, and it has no means to make up abilities the page never published.

That last point is the heart of the idea. Chrome's documentation describes the API clearly enough, but reading an explainer isn't the same as understanding a thing, so the quickest route was to build the smallest possible version.

The demo: a task board in three files

The board consists of index.html, app.js and style.css. Nothing needs building or installing. People can add tasks, check them off and filter the list, which is the sort of project that takes an afternoon. What's new is that the page also presents itself to an AI agent as a set of callable tools, via one object the browser supplies.

const mc = document.modelContext;   // undefined if WebMCP is off

That single line is the entire entry point. When the object exists, the page can register tools. When it doesn't, the page is just a task board, which is how it should degrade.

To keep both ends visible, the demo page puts them side by side. On the left sits the board, the provider. On the right, a "Pretend Agent" panel plays the consumer and issues getTools() and executeTool() calls just as a real agent would, which avoids any extra wiring. You'll see that panel in every screenshot below.

WebMCP still carries the status Proposed on Chrome Platform Status and isn't on by default anywhere, so a flag is needed first.

Open chrome://flags/#enable-webmcp-testing, set it to Enabled and relaunch. Without that, document.modelContext is undefined. Chrome also offers a WebMCP origin trial that starts with Chrome 149. The flag suits local experiments, while the origin trial is the route Chrome documents for broader testing.

The finished loop first

It's easier to understand the API after seeing what it produces. Claude Code was connected to the board and given one sentence asking it to add four tasks: finding information and doing research, writing blog outlines, crafting a full blog with examples, and publishing it. Four tasks appeared. A second sentence asked it to mark tasks 3, 4 and 5 as done, and that worked too.

Claude didn't screenshot the board or read its HTML. It queried the browser for the available tools, spotted add-task and invoked it four times with structured arguments. Here's the same moment viewed from within the browser.

The four new tasks now sit at #3 to #6 on the board, and the DevTools panel (Application, then WebMCP) lists all six tools the page currently publishes. One of them, clear-completed, wasn't there a minute earlier, and the reason comes up shortly. The three completions are logged call by call.

Every call shows up with its arguments ({"id":3}, {"id":4} and {"id":5}), and the summary line reads three calls in total, with no failures, cancellations or calls still running. Debugging WebMCP is quickest here, because the panel displays precisely what the agent sent.

So a sentence typed into a terminal became four structured function calls inside a browser tab. The next question is what the page had to do to earn that.

Two ways to publish a tool

The first needs no JavaScript at all. An existing form gets four extra attributes.

<form id="add-form"
      toolname="add-task"
      tooldescription="Add a new task to the user's task board."
      toolautosubmit>
  <input name="title" required maxlength="80"
         toolparamdescription="Short description of the task to add.">
  <select name="priority"
          toolparamdescription="How urgent the task is.">
    <option value="low">low</option>
    <option value="normal" selected>normal</option>
    <option value="high">high</option>
  </select>
  <button type="submit">Add</button>
</form>

That form is the add-task tool Claude invoked four times. Field names, required attributes and <select> options are all read by the browser, which assembles the JSON Schema on its own. The agent sees one required string and a three-value enum, and nobody typed the schema. In an existing application it's likely the simplest WebMCP experiment, because an ordinary HTML form means there's a head start. All that's added is a statement of what the form means to an agent.

One detail takes a moment to appreciate: the same submit handler serves a person clicking Add and an agent calling the tool, and the event says which one it was.

$('#add-form').addEventListener('submit', (e) => {
  e.preventDefault();
  const task = addTask(new FormData(e.target).get('title'), /* ... */);

  if (!e.agentInvoked) {
    e.target.reset();          // human — clear the box
  } else {
    e.respondWith?.(Promise.resolve(
      text(`Added task #${task.id}: "${task.title}".`)
    ));
  }
});

The agentInvoked flag tells you who pressed the button, and respondWith() returns a result to the agent without navigating away. There's one code path with two callers, which is a good design choice, because nobody wants an "AI version" of every action in an app. The same logic should serve people and agents whenever that makes sense.

The declarative route is arguably the less interesting half, though it's the one likely to attract attention, since it demos nicely and takes no thought. What a form can't express is where it gets interesting. That's the second route, registerTool, which looks much like what you'd write for a backend MCP server.

await mc.registerTool({
  name: 'list-tasks',
  description: 'List the tasks on the board. Use this before acting so you know the task IDs.',
  inputSchema: {
    type: 'object',
    properties: {
      status: { type: 'string', enum: ['all', 'open', 'done'] },
    },
  },
  annotations: { readOnlyHint: true },
  async execute({ status = 'all' }) {
    const rows = visible(tasks, status);
    return text(rows.map((t) => `#${t.id} [${t.done ? 'x' : ' '}] ${t.title}`).join('\n'));
  },
});

Look closely at that description. It's written for a model and not a developer, and a line such as "use this before acting so you know the task IDs" hints at when to call it. Descriptions are now part of the API surface, and vague ones produce agents that guess.

The readOnlyHint flag signals that a tool is safe to call without asking the user first. list-tasks and estimate-task carry it. complete-task and clear-completed don't, because they change things.

Tools can also be slow, and an agent may change its mind midway. The estimate-task tool waits three seconds on purpose, and it receives an AbortSignal as its second argument.

async execute({ id }, { signal }) {
  const t = findTask(tasks, id);

  if (!t) {
    return text(`No task #${id}.`);
  }

  await sleep(3000, signal); // throws if the agent aborts

  return text(
    `Task #${t.id} is about ${
      t.priority === 'high' ? '2 hours' : '30 minutes'
    }.`
  );
}

Respect that signal and cancellation works. Ignore it and the tool keeps running after the agent has left.

A tool list that changes while the page is open

The strongest reason to register tools in script, as opposed to shipping a static manifest, is that the list is allowed to change at runtime. The board has a clear-completed tool that deletes every finished task. That only makes sense when something is finished, which explains why it appeared in the earlier screenshot right after three tasks were marked done. So it exists only at those moments.

let clearCtl = null;

async function syncClearTool() {
  const has = tasks.some((t) => t.done);

  if (has && !clearCtl) {
    const ctl = new AbortController();
    clearCtl = ctl;

    await mc.registerTool(
      {
        name: 'clear-completed',
        /* ... */
      },
      {
        signal: ctl.signal
      }
    );
  } else if (!has && clearCtl) {
    const ctl = clearCtl;
    clearCtl = null;

    setTimeout(() => ctl.abort(), 0); // abort() IS unregister
  }
}

There's no unregisterTool(). You pass an AbortSignal at registration, and aborting it removes the tool. That's when the design clicks: the signal that cancels a running call also revokes the capability, one mechanism doing two jobs.

Tick a task as done and clear-completed shows up in the agent's list, then untick it and the tool disappears. The agent hears about it through a toolchange event and doesn't need to poll.

mc.addEventListener('toolchange', refreshTools);

So the tool list isn't a manifest published once. It describes what the app can do right now, in its current state.

How this differs from a backend MCP server

Next, Claude Code was asked to narrow the board to completed tasks.

The agent called filter-tasks with {"status":"done"}, the Done button became active and the board showed only finished tasks. The agent hadn't pulled a filtered copy of the data. It changed the view the user was looking at.

A backend MCP server can't pull this off. Sitting on the far side of a network boundary, it operates on stored data and knows nothing about what any screen shows right now. WebMCP tools live inside the tab, sharing its DOM, session and cookies, so agent and person view one screen and either one may alter it. That's a genuine strength and a genuine risk, and the risk gets its own section below. It's also why calling WebMCP "MCP inside the browser" undersells it. Where the tool lives matters, since it's attached to the very page state the user is looking at.

Connecting an outside agent

The Pretend Agent panel helps during development, yet it proves little, since it's the page calling itself. A proper test needs something outside the browser that can see only what getTools() reports. So there's agent.mjs, a small command-line tool with a bit of Chrome DevTools Protocol plumbing and zero dependencies, because Node 22 has a global WebSocket. By construction it can see only what getTools() reports and act only through executeTool(), with no DOM scraping available.

node agent.mjs list
node agent.mjs call add-task '{"title":"Ship the demo","priority":"high"}'

Those two commands list the tools and then call add-task with a title and a priority. The setup that produced the screenshots goes one step further. Claude Desktop, Claude Code and ChatGPT speak MCP and not WebMCP, so mcp-bridge.mjs acts as the adapter, and a small one, because the protocols line up almost exactly: tools/list maps to getTools() and tools/call maps to executeTool(). Claude Code picks it up through a .mcp.json file.

{
  "mcpServers": {
    "webmcp-board": {
      "command": "node",
      "args": ["C:\\projects\\web-mcp-demo\\mcp-bridge.mjs"],
      "env": { "PAGE_URL": "https://tusharkanjariya.github.io/web-mcp-demo/" }
    }
  }
}

The whole chain is therefore Claude Code speaking MCP to the bridge, the bridge speaking CDP to Chrome, and Chrome calling the tools the page registered. That's four hops, and the page at the end can't tell which client is on the other side.

Claude Code
    ↓
MCP
    ↓
mcp-bridge.mjs
    ↓
Chrome DevTools Protocol
    ↓
WebMCP
    ↓
my task board

One more convenience: the bridge launches its own Chrome if nothing answers on the debug port, and opens the page when the tab isn't there. An MCP client can start everything cold.

The refusal

Then the agent was asked to delete a single task.

The top half of the screenshot shows the filter request from before. The bottom half is the interesting part: Claude said no delete-task tool exists on this board, that the only deletion available is clear-completed, which would also wipe tasks #2, #3, #4 and #5, and that it wasn't going to do that.

Nothing like a guardrail, system prompt or safety layer produced the refusal. The ability to delete a single task simply wasn't there, and the agent noticed. It would be a mistake to read much into one exchange on a toy app. Still, it shifts the way the problem looks. An agent steering a page by pixels and coordinates would have located something delete-like and tried it, because screen-scraping agents have only improvisation to fall back on. This one counted its six tools, saw that none meant removing one task, recognized the collateral damage in the closest option and paused to ask.

The tool surface was the boundary. Nobody wrote a rule against deleting things. The page just never offered deletion as something that could happen, and that proved enough, even though WebMCP doesn't magically make agents safe.

Where Chrome and the spec disagree

Running the thing turned up differences between documentation and browser, and sorting them out cost an afternoon. Three are about data shapes.

  • Arguments to executeTool(): the spec describes an object, but Chrome 151 expects a JSON string, and an object triggers Failed to parse input arguments.
  • RegisteredTool.inputSchema: typed as an object in the spec, but Chrome hands back a JSON string, so it has to be parsed before reading properties.
  • What executeTool() resolves to: the spec says a DOMString, which is true, but it's a serialized {content:[…]} envelope that must be unwrapped twice to reach the text.

None of that is hard once you know it. The two that actually hurt were lifecycle bugs, and they only appear once a real agent drives the page.

A call to form.reset() while an agent-invoked submit is running cancels the tool call with Tool execution cancelled by a form reset. A perfectly ordinary handler that empties the input after submitting, as most do, caused every agent-driven add-task to die. The task was created and the reply never arrived, which is why the handler shown earlier resets only for human submissions.

In versions before Chrome 153, removing a tool also cancels any of its calls that are still running. That's harmless until a tool removes itself as a result of succeeding. clear-completed deletes every finished task, so nothing remains done, and the tool withdraws itself. On 151, that abort cut off its own reply before it was returned. The remedy is setTimeout(() => ctl.abort(), 0): postponing the abort by one tick lets the call resolve first. It's explainable and it works, but deferring teardown by a tick is the kind of patch worth revisiting after a few Chrome releases.

Shipping the demo

The demo is entirely static, so GitHub Pages can serve it unchanged, and HTTPS supplies the secure context WebMCP demands. Everyone gets a functioning task board that way, but not functioning WebMCP, since visitors who haven't set the flag still see document.modelContext undefined. Fixing that takes an origin trial token in the <head>, registered as first-party because Chrome rejects third-party tokens placed in a meta tag. On a clean profile with no flags, modelContext came back as "object" along with all six tools. The trial lasts through Chrome 156.

The security question without a good answer

WebMCP tools execute inside a logged-in tab. That's the entire point, and it's also the entire problem.

The spec includes an untrustedContentHint, which is only a hint. How prompt injection should be handled is still unresolved there, and no part of this API acts as an authorization boundary. If attacker-controlled text can get rendered on a page and an agent reads it, readOnlyHint can't help.

The bridge can also be served over HTTP through a tunnel so ChatGPT's connectors can reach it. It functions, but think carefully before doing it, because the tunnel exposes an endpoint with no authentication that runs tools inside your logged-in browser tab for whoever guesses the address. Twenty minutes of experiments is fine, and leaving it up overnight isn't.

Trying it on your own app

Maintainers of a web app can judge the API in an afternoon, because it's small. Pick any existing form, give it toolname and tooldescription attributes, then open the WebMCP panel in DevTools and see the tool appear. About ten minutes of that teaches more than another hour of reading.

The takeaway goes beyond syntax. Saying truthfully what your app can do has become part of what you ship, rather than documentation written afterward. The board offered no single-task delete, so the agent never deleted one. What getTools() returns determines how an agent pictures your app's abilities, and that picture is steadily turning into the interface itself. The demo came to six tools across three files with no dependencies, so consider what your own page would say about itself. To reproduce it, turn on the chrome://flags/#enable-webmcp-testing flag, then execute node agent.mjs list. Getting six tools in response means you are talking to a web page the way an agent would.

Quick answers

What's WebMCP? A proposed browser API through which a page publishes its own features to an AI agent as callable tools, using document.modelContext. Agents call those tools directly and skip DOM scraping and coordinate clicking.

How's it unlike a regular MCP server? Backend servers run beyond a network boundary and act on stored data. WebMCP tools run in the tab, so they can alter what the user sees and they reuse the page's session and permissions. Calling filter-tasks visibly changed the board.

How does a page register a tool? Either add toolname and tooldescription attributes to an existing form and let the browser derive the schema from the fields, or call document.modelContext.registerTool() with a name, a description, a JSON Schema and an execute function. Forms suit simple actions, and registerTool covers anything a form can't express.

Is a flag required? For now, yes. Enable the flag and relaunch Chrome, or start it with --enable-features=WebMCP. For visitors who haven't set the flag, a first-party origin trial token in the page's <head> works.

Can Claude Desktop or ChatGPT call these tools? Not by themselves, because they use MCP rather than WebMCP. A small adapter maps tools/list onto getTools() and tools/call onto executeTool(). The one here runs to about 130 lines, needs no dependencies and works over stdio for local clients.

Is it secure? Consider the question open. untrustedContentHint and readOnlyHint are just hints, neither marks an authorization boundary, and the spec hasn't settled prompt injection. Build the tool surface assuming an agent could be talked into calling anything you expose.