Command Palette

Search for a command to run...

UnylyUnyly
Browse all

Webmcpable

FreeMaintained

Let AI agents use your web app. WebMCP tools that mirror what the user can actually do right now.

GitHubEmbed

About

Let AI agents use your web app. WebMCP tools that mirror what the user can actually do right now.

README

npm license

Let AI agents use your web app. Declare your app's actions as tools an agent can call. They run in the page, on your existing application logic, in the user's session.

npm install webmcpable

Writing these tools with a coding agent? The package ships SKILL.md — point your agent at node_modules/webmcpable/SKILL.md, or copy it to .claude/skills/webmcpable/SKILL.md.

What WebMCP is

Ask an agent to buy something and watch what it does. It screenshots the page, looks for something resembling a button, clicks, waits, screenshots again. It reads your screen the way you would, slower, and you pay tokens every time it looks. Ship a redesign and it breaks.

Your site already knows it has a search, a cart, and a checkout flow. None of that is written down where a program can read it. The layout describes it to people and to nobody else.

WebMCP is a browser API from the Chrome and Edge teams that lets your page write those actions down. Each action gets a name, a description a model reads, and a schema for its inputs. The agent calls them instead of inferring them from pixels.

That change buys you three things:

  • The user's session, with no keys. The tool runs in the open tab. Your user has signed in, so the agent already has whatever access they have. You provision nothing and hand out no tokens.
  • An action list that follows the page. A signed-out visitor's agent sees search and product lookup. After sign-in the same site offers order history and checkout. The agent reads the list again.
  • Portability across models. Inputs use JSON Schema, the format Claude, GPT, and Gemini already accept for tool calls. Describe an action once.

Can you use this today?

Chrome 151 ships WebMCP behind an origin trial and Edge is building it. The draft spec still moves.

webmcpable is safe to ship now. mount() and revalidate() do nothing when the browser has no document.modelContext, so users on every other browser carry no cost. To build against WebMCP without a supporting browser, use the in-memory harness in webmcpable/testing, and webmcpable/testing/playwright for end-to-end tests.

Two things this library leaves alone. It does not bridge desktop MCP clients to your page, and it does not polyfill WebMCP. The browser owns declarative <form toolname> support. If you mix the two, reference the attribute types so TypeScript accepts them:

/// <reference types="webmcpable/declarative" />

A form registers a tool as surely as registerTool does, and the browser refuses a name that is already taken — so tools() reports webmcpable could not register "book_table": InvalidStateError: Duplicate tool name, and the form keeps the name. A declarative tool without toolautosubmit also parks the agent's call until a human presses the button. Both measured in e2e/declarative.conformance.ts.

Your first tool

import { tools } from 'webmcpable'
import * as z from 'zod'

const registry = tools({
  checkout: {
    description: 'Check out the current cart and place the order.',
    when: () => cart.items.length > 0,        // only offered when it is possible
    input: z.object({ address: z.string().describe('Delivery address') }),
    execute: ({ address }) => placeOrder(address),
  },
})

await registry.mount()

webmcpable infers the type of address from the schema and validates the agent's input before your handler runs. The browser exposes checkout only while the cart holds an item.

Why not call the browser API directly

You can. document.modelContext.registerTool() is a plain function and needs no library. The catch is that Chrome 151 diverges from the draft in several places, and each divergence turns a small mistake into a silent one.

Behavior measured in Chrome 151:

You write The agent receives
return { content: [{ type: 'text', text: 'Added' }] } {"content":[{"type":"text","text":"Added"}]}, the wrapper, unparsed
return undefined "undefined", the literal nine characters
throw new Error('out of stock') UnknownError: Tool was executed but the invocation failed
annotations: { destructiveHint: true } nothing. Discarded at registration
executeTool(tool, { q: 'x' }) UnknownError: Failed to parse input arguments

webmcpable normalizes handler results and errors, validates Standard Schema input before your handler sees it, and restricts annotations to the fields WebMCP defines. The test harness and CLI reproduce or detect invocation mistakes such as passing object arguments to executeTool().

spike/SPIKE-FINDINGS.md records every measurement behind this table, names the file that encodes each one, and explains how to re-run them against a newer Chrome.

Tool results

A handler returns a string, a JSON-compatible value, or undefined. webmcpable passes strings through, serializes other values as JSON, and maps undefined to an empty result. Chrome presents that empty result as "Operation succeeded".

Chrome replaces a thrown error with a generic UnknownError. webmcpable catches it and returns Error: <message>, so the agent reads what went wrong.

Return what changed, not that something changed

"Filters updated" forces the agent to call another tool to find out what it did, and that second call races your UI: it can read the page before the framework has rendered your change. Return the new state from the tool that made it and the race is gone.

set_filters: {
  description: 'Filter the flight results.',
  input: z.object({ maxPrice: z.number() }),
  execute: ({ maxPrice }) => {
    const flights = applyFilters({ maxPrice })
    return { count: flights.length, flights }   // not 'Filters updated'
  },
}

Tools that navigate

A tool that navigates unloads the document that still owes the agent a result. Measured in Chrome 152: a value returned in the same turn as the assignment arrives, and anything awaited after it is lost with no error on either side. Do not rely on the first half — resolve first, navigate from a task:

view_product: {
  description: 'Open the page for a product.',
  input: z.object({ id: z.string() }),
  execute: ({ id }) => {
    setTimeout(() => location.assign(`/product/${id}`), 0)
    return `Opening ${id}.`
  },
}

doctor flags a tool that navigates without deferring. Client-side routers are fine — nothing unloads. Call mount() again on the page you land on.

The path an agent actually takes

Everything above describes the page's side. An agent is not in the page. Chrome exposes WebMCP to a client over a CDP WebMCP domain — toolsAdded, invokeTool, cancelInvocation, toolResponded — and the reference client is agent-browser, whose agent-browser webmcp list | invoke | result | cancel is what calling your page looks like from outside.

That crossing has rules the in-page API does not, all measured in e2e/cdp.conformance.ts:

In the page What a client sees
return { count: 3 } output: { "count": 3 } — structured. Chrome parses the JSON webmcpable serialised
return 'Added' output: "Added"
return undefined output: "Operation succeeded"
throw new Error('out of stock') status: "Completed", output: "Error: out of stock"
a tool registered in a same-origin child frame nothing. Never advertised

The fourth row is a trade rather than a bug. Chrome replaces a thrown message with a generic UnknownError on the in-page path, so webmcpable catches and returns the text — which is the only way an agent reads what went wrong. The cost is that a client watching status alone sees success. A tool that throws without the library reports status: "Error", and an unreadable message.

Your handler's second argument

execute is typed (input, { signal }), and Chrome calls a registered execute with one argument. webmcpable supplies the second itself, so the documented signature works; called directly, registerTool would hand your handler undefined and a destructure would throw.

That signal is the tool's registration. It aborts when the tool is unregistered — unmount(), or a revalidate() where when stopped holding — so a long-running handler can drop work the user can no longer reach.

It is not a per-call cancellation. A client can cancel an invocation, and Chrome tells the client it was Canceled while telling the page nothing: the handler keeps running, its promise never settles, its signal never fires. Do not leave a handler waiting on something that may never arrive.

Tools in an iframe

A child frame needs allow="tools" before it may register at all. Even with it, a same-origin child's tools reach the page's own getTools() and are never advertised to a client attached to the top-level page — no error on either side. Register from the top-level document. A cross-origin child is a separate target with its own tool set, which only a client that attaches per-frame will read.

What a client refuses

agent-browser caps input at 1 MB, output at 2 MB, a tool record at 256 KB and the list at 512 tools, and returns webmcp_output_too_large rather than truncating. Returning the state you changed is still right — return the fields the agent needs, not every row you have.

Tools that follow page state

A tool the agent cannot use is worse than no tool. when: decides whether to offer each one, so an empty cart hides checkout instead of failing it.

tools({
  view_cart:  { description: '...', annotations: { readOnlyHint: true }, execute: showCart },
  checkout:   { description: '...', when: () => cart.items.length > 0, execute: checkout },
  cancel:     { description: '...', when: () => order.status === 'pending', execute: cancel },
})

A tool that disappears tells the agent nothing about why. when can return a reason instead of false: the tool stays listed and refuses every call with that string, so the agent can explain the gap to the user and carry on with the rest of the workflow.

export_report: {
  description: 'Export this report as a CSV.',
  when: () => plan.exports || 'Exports are not included in this workspace plan.',
  execute: exportReport,
}

Call registry.revalidate() after state changes. The framework adapters below call it for you as their framework updates.

Framework adapters

React

import { useTools } from 'webmcpable/react'

function Cart({ items }) {
  useTools({
    checkout: {
      description: 'Check out the current cart.',
      when: () => items.length > 0,
      execute: placeOrder,
    },
  })
  return <CartView items={items} />
}

useTools registers on mount, unregisters on unmount, and revalidates after each render. It supports StrictMode.

Vue

import { useTools } from 'webmcpable/vue'

useTools({
  checkout: {
    description: 'Check out the current cart.',
    when: () => cart.value.items.length > 0,
    execute: placeOrder,
  },
})

A watchEffect tracks the reactive state your when predicate reads, so the tool list follows the UI without a manual revalidate(). Tools unregister when the component's scope closes.

Svelte

import { useTools } from 'webmcpable/svelte'

useTools({
  checkout: {
    description: 'Check out the current cart.',
    when: () => cart.items.length > 0,
    execute: placeOrder,
  },
})

Runs on $effect, which tracks the runes your when predicate reads. Call it in a component, or inside $effect.root for app-level tools.

Effect

import { effectTools } from 'webmcpable/effect'

const registry = yield* effectTools({
  checkout: {
    description: 'Check out the current cart.',
    when: () => Effect.map(SubscriptionRef.get(cart), (n) => n > 0),
    execute: ({ address }) => placeOrder(address),
  },
}, { watch: [cart] })

yield* registry.mount

effectTools accepts Effect handlers and preserves typed failures. A change to a watched ref triggers revalidation. Closing the scope unregisters the tools.

The same tools, on-device

Chrome ships a second model surface: the Prompt API, Gemini Nano running in the page. Behind chrome://flags#prompt-api-tool-use it takes tools — and the shape it takes them in is the one you have already written.

import { localTools } from 'webmcpable/local'

const session = await LanguageModel.create({
  tools: localTools(defs),
})

await session.prompt('Add two coffees to my cart and check out.')

defs is the same object you pass to tools(). Declare an action once and it is available in both directions: outward to a remote agent through document.modelContext, and inward to a model with no network at all. when gating, input validation and the confirm gate behave identically on both paths — a tool the user cannot reach does not become reachable because the model asking for it happens to live in the page.

Two differences from a registry. There is nothing to revalidate: a session holds the tools it was created with, so build the array again per create() (or per clone()) to pick up a change — a tool whose when turned false meanwhile still refuses at call time, so a stale array cannot run something the user can no longer do. And exposedTo and annotations are WebMCP's, so the Prompt API ignores them, except readOnlyHint, which still decides whether confirm asks.

Everything else in this README is measured against a real Chrome. This is not: tool use is undocumented outside the explainer, and the conformance lane cannot run it without a Canary and a model download. Treat the shape as the explainer's until that changes.

Test without a supporting browser

webmcpable/testing installs an in-memory document.modelContext, so your application tests run in Node.

import { installTestModelContext } from 'webmcpable/testing'

const mc = installTestModelContext()
await registry.mount()

const [tool] = await document.modelContext.getTools()
await document.modelContext.executeTool(tool, '{"address":"12 High St"}')

expect(mc.calls).toEqual([
  { name: 'checkout', input: { address: '12 High St' }, result: 'Order placed' },
])

The harness reproduces Chrome's JSON-string arguments, result serialization, error handling, registration validation, and lexicographical tool ordering.

End-to-end, in Playwright

webmcpable/testing puts the fake in the test process, which is all a component test needs. An end-to-end test has the opposite problem: the application runs in a browser, and Playwright's bundled Chromium carries no WebMCP at all. webmcpable/testing/playwright installs the same fake in the page instead, so a suite that never sees a flagged Chrome can still call the tools an agent would call.

import { expect, test } from 'webmcpable/testing/playwright'

test('an agent calling a tool moves what the user sees', async ({ modelContext, page }) => {
  await page.goto('/cart')

  const result = await modelContext.callTool('add_to_cart', { qty: 2, sku: 'espresso' })

  expect(JSON.parse(result)).toEqual({ sku: 'espresso', total: 2 })
  await expect(page.getByTestId('cart-count')).toHaveText('2')
})

The second assertion is the one that carries weight. A tool reporting success over a cart that never changed has told the agent a lie, and only the UI check catches it.

modelContext offers three things:

  • getTools(), with inputSchema already parsed. The browser hands that back as a JSON string, and reaching for .properties on it is the most common way a WebMCP test passes for the wrong reason.
  • callTool(name, input), which serialises the input to the JSON string Chrome demands and looks the tool up the way an agent does.
  • calls(), every invocation so far with its input, for asserting what the agent actually did rather than only what the page now shows.

For a suite that already has its own fixtures, installTestModelContext(page) is the same thing without the test export. Call it before the first goto — the fake goes in through addInitScript, so it has to be in place before the page registers anything.

import { test as base } from '@playwright/test'
import { installTestModelContext } from 'webmcpable/testing/playwright'

export const test = base.extend({
  modelContext: async ({ page }, use) => {
    await use(await installTestModelContext(page))
  },
})

Two lanes, because a transcription drifts

webmcpable/testing is a hand-written copy of what a browser did when someone last looked. That is fast and runs anywhere, and it is only as true as its last reading. So there is a second lane that reads the original again:

CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  pnpm --filter webmcpable test:conformance

It drives a real Chrome — 152 or newer, launched with the WebMCP flags — against the built bundle, asserting the same things the in-memory harness asserts. When it fails, the copy is wrong, not the browser. It regenerates e2e/CHROME-CONFORMANCE.md as it goes, so the measurements are output rather than prose someone remembered to update.

A third lane drives the same build the way an agent does, over CDP rather than from inside the page, because those are not the same code path — e2e/cdp.conformance.ts. It is what measured that Chrome calls execute with one argument, which is why webmcpable supplies the second.

A fourth lane, e2e/bundled-chromium.fixture.ts, runs webmcpable/testing/playwright in Playwright's own Chromium — the browser with no WebMCP in it — because that is the browser the helper exists for. Its first assertion is that Chromium still has no document.modelContext, so the lane cannot start passing for the wrong reason.

Use the same split in your own app: unit tests against webmcpable/testing on every commit, end-to-end tests against webmcpable/testing/playwright, and a small conformance lane against real Chrome to catch the day the browser moves.

Hand your tools to an eval

Both lanes above answer "does this tool work". Neither answers "will a model pick it" — that needs a model, and webmcp-evals already does it. Its local mode reads your tool list from a JSON file, and toolSchemas() writes that file from the registry you already have, so the schemas the model is judged on cannot drift from the ones you ship:

import { installTestModelContext, toolSchemas } from 'webmcpable/testing'

installTestModelContext()
await registry.mount()
writeFileSync('schema.json', JSON.stringify(await toolSchemas(), null, 2))
npx webmcp-evals local -t schema.json -e evals.json

JSON.stringify(await getTools()) cannot stand in for this — a RegisteredTool carries its owner Window and throws on a circular structure.

doctor and the debug panel are the cheap version of the same question: a thin description or an undescribed parameter is a tool a model will misfire on, and neither costs an API key.

What this protects, and what it cannot

An agent driving your site runs in the session the user is already signed into, and the user's only checkpoint is a consent dialogue the browser renders, not your page. A RegisteredTool carries no reference to the code behind it, so nothing in the page can bind the name a user approved to the function that runs. That gap belongs to the browser.

What this library does is keep an honest site's tools honest:

  • A tool is refused at execution time when its when predicate no longer holds, not merely hidden from the next listing.
  • A description or schema that changes is re-registered, so an agent never reads a descriptor the page has moved past.
  • { titles: 'off' } withholds title from the browser, so a consent dialogue cannot promote a friendlier label over the name that runs.
  • confirm asks again on the execute path, bound to the resolved name and arguments. Read-only tools skip it. This is a second check, not a replacement for the browser dialogue.
  • doctor fails the build on a description built by interpolation — the usual way user content becomes text an agent reads as instruction — and on an exposedTo origin the browser will refuse. It warns on a title that does not contain the tool name, a page-initiated executeTool, and a mutating tool with no when.
  • Both doctor and the panel measure names, descriptions and results against the character budgets the Chrome team recommends: 30 for a name, 500 for a description, 150 for a parameter description, 1.5K for a result. They are agent guardrails rather than browser limits, so they warn.
  • The debug panel flags a description phrased as an order to the agent, a title that does not match the name, a tool redefined under a name the agent already had, and a journal of the resolved calls.

None of that stops prompt injection, and none of it helps against a site that means you harm — such a site would not use this library. It makes accidental versions of those problems loud in development instead of silent in production. The three rules the browser dialogue still needs are in spike/HONEST-HANDSHAKE.md.

Log what an agent did

onCall fires once per resolved call, on both the WebMCP and on-device paths, so an agent's traffic reaches whatever you already send telemetry to.

tools(defs, {
  onCall: ({ input, ms, name, result }) => track('webmcp.call', { input, ms, name, result }),
})

input is what the agent sent and result is exactly the string it received, so a refusal from when, a validation failure and a thrown handler all arrive here alongside the successes — the tool an agent keeps being refused is as worth seeing as the one it runs. A throw from your sink is swallowed, so logging stays outside the transaction.

Inspect what an agent sees

import { mountDebugPanel } from 'webmcpable/debug'
mountDebugPanel()

The panel lists every registered tool next to the result string an agent receives. It flags MCP envelopes, thin descriptions, undescribed parameters, invalid names, descriptions phrased as instructions to the agent, a title that does not match the name, and tools redefined under a name the agent already had. It flags a document that is a child frame, whose tools a client may never be advertised. It keeps a journal of resolved calls — name and arguments, not the label. Copy its report as Markdown.

Track the draft

npx webmcpable doctor        # scan for known browser and draft hazards
npx webmcpable spec-check    # report changes to the draft WebIDL

Treat spec-check as a drift alarm rather than a correctness oracle. doctor reads your source for hazards Chrome hides at runtime: an invented destructiveHint or an outputSchema, both discarded without an error; a description built by interpolation; a tool that navigates away before its result is delivered.

API vocabulary

Set annotations: { untrustedContentHint: true } on a tool that returns user content or anything fetched from elsewhere, and readOnlyHint: true on one that changes nothing — the second also skips confirm.

tools() returns a registry. mount registers, revalidate re-evaluates every when, unmount aborts. A tool is a name, a description, an optional input schema, and an execute function. Set exposedTo when callers in other origins of the current document tree need access — exact secure origins only, measured in e2e/model-context.conformance.ts: Chrome refuses plain http: and has no wildcard. Pass { titles: 'off' } to withhold title from the browser, and confirm (a function, or true for window.confirm) to ask before a mutating tool runs.

confirm also goes on a single tool, so the question lands on the one action that warrants it. A tool's own setting wins outright, including over readOnlyHint: add_to_cart opts out of a registry-wide gate, and a read that exports the customer list can still ask.

tools({
  add_to_cart:    { description: '...', execute: add },
  delete_account: { description: '...', confirm: true, execute: close },
})

execute receives (input, { signal }). That signal aborts when the tool is unregistered, not when an agent cancels a call — see the path an agent actually takes.

localTools() takes the same definitions and returns a plain array for LanguageModel.create({ tools }) — no registry, no lifecycle.

input accepts Zod 4 and ArkType schemas and converts them to JSON Schema for you. It also accepts raw JSON Schema, which webmcpable passes through without runtime validation. Standard Schema validation alone cannot produce the JSON Schema that WebMCP registration requires, so convert a Valibot schema with @valibot/to-json-schema and pass the result as raw JSON Schema.

Licence

MIT

from github.com/jagreehal/webmcpable

Install Webmcpable in Claude Desktop, Claude Code & Cursor

Recommended · one command, every IDE
unyly install webmcpable

Installs into Claude Desktop, Claude Code, Cursor & VS Code — handles npx, uvx and build-from-source repos for you.

First time? Get the CLI: curl -fsSL https://unyly.org/install | sh

Or configure manually

Run in your terminal:

claude mcp add webmcpable --env CHROME_BIN="" -- npx -y webmcpable

Step-by-step: how to install Webmcpable

FAQ

Is Webmcpable MCP free?

Yes, Webmcpable MCP is free — one-click install via Unyly at no cost.

Does Webmcpable need an API key?

Yes, it requires environment variables: CHROME_BIN. Unyly injects them into the config during install.

Is Webmcpable hosted or self-hosted?

Self-hosted: the server runs locally on your machine via the install command above.

How do I install Webmcpable in Claude Desktop, Claude Code or Cursor?

Open Webmcpable on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.

Related MCPs

Playwright

Browser automation, scraping, screenshots

Microsoftby Microsoft

Puppeteer

Browser automation and web scraping.

modelcontextprotocolby modelcontextprotocol

Garmin Connect

An MCP server for Garmin Connect that provides access to fitness activities, health statistics, and sleep data by routing requests through a headless browser to

etweisbergby etweisberg

Higgsfield Unlimited

MCP server for Higgsfield AI that enables unlimited-mode image, video, audio generation, uploads, and job management via multiple parallel accounts, using brows

nukIeerby nukIeer

opentabs-dev/opentabs

Plugin-based MCP server + Chrome extension that gives AI agents access to web applications through the user's authenticated browser session. 100+ plugins with a

opentabs-devby opentabs-dev

robhunter/agentdeals

1,500+ developer infrastructure deals, free tiers, and startup programs across 54 categories. Search deals, compare vendors, plan stacks, and track pricing chan

robhunterby robhunter

hlydecker/ucsc-genome-mcp

MCP server to interact with the UCSC Genome Browser API, letting you find genomes, chromosomes, and more.

hlydeckerby hlydecker

34892002/bilibili-mcp-js

A MCP server that supports searching for Bilibili content. Provides LangChain integration examples and test scripts.

34892002by 34892002

achiya-automation/safari-mcp

Native Safari browser automation for AI agents with 80+ tools. No Chrome dependency, optimized for Apple Silicon with 60% less CPU overhead.

achiya-automationby achiya-automation

agent-infra/mcp-server-browser

Browser automation capabilities using Puppeteer, both support local and remote browser connection.

bytedanceby bytedance

Compare Webmcpable with

Not sure what to pick?

Find your stack in 60 seconds

Author?

Embed badge for your README

Browse similar

All browse MCPs