fastwebmcp

TypeScript · WebMCP

Give the browser its own tool voice.

fastwebmcp brings FastMCP-style ergonomics to WebMCP: typed Zod builders over the browser's Imperative and Declarative APIs — with a safe no-op fallback for the browsers (most of them, still) that don't speak document.modelContext yet.

$ npm install fastwebmcp v0.5.0 · latest npm ↗ Source ↗
<form
  toolname="submit_support_request"
  tooldescription="Submit a request for support.">

  <select name="topic"
    toolparamdescription="Routes to a team.">
  … options …
  </select>

  <button>Submit</button>
</form>

↑ three attributes, set by defineDeclarativeTool() — not written by hand

The gap

Raw WebMCP asks you to hand-write a JSON Schema and trust the input.

MCP had the same problem, until FastMCP made a Python type hint into a working tool definition. WebMCP is at that same point today — the official examples register tools with a schema typed out by hand and no validation between the browser and your function.

Without fastwebmcp
document.modelContext.registerTool({
  name: 'add_todo',
  description: 'Add a todo item.',
  inputSchema: {
    type: 'object',
    properties: {
      text: { type: 'string', minLength: 1 }
    },
    required: ['text']
  },
  execute: async (raw) => {
    // raw is unchecked — validate it yourself
    return addTodo(raw.text);
  }
});
With fastwebmcp
import { z } from 'zod';
import { registerTool } from 'fastwebmcp';

registerTool({
  name: 'add_todo',
  description: 'Add a todo item.',
  inputSchema: z.object({
    text: z.string().min(1)
  }),
  execute: async ({ text }) => {
    // text is already validated & typed
    return addTodo(text);
  }
});

Two surfaces

Register a function, or annotate a form — your call.

WebMCP ships two ways to expose a tool. fastwebmcp gives both the same validate-then-run discipline.

Imperative

defineTool() + registerTool()

registerTool({
  name: 'search_flights',
  description: 'Search flights by route.',
  inputSchema: z.object({
    from: z.string(), to: z.string()
  }),
  execute: async (input) => searchFlights(input)
});
// falls back to console.warn() + no-op
// on a browser without document.modelContext

Declarative

defineDeclarativeTool() + respondToAgentSubmit()

defineDeclarativeTool(form, {
  name: 'submit_support_request',
  description: 'Submit a request for support.',
  fields: [{ name: 'topic', description: 'Routes to a team.' }]
});

form.addEventListener('submit', (e) => {
  respondToAgentSubmit(e, () => submitRequest(e));
});

New in v0.5.0

Let the agent request the action. Keep secrets and authority local.

The optional fastwebmcp/lsfa module connects an Imperative WebMCP tool to a trusted LSFA broker. The agent sees only non-sensitive intent; the broker owns secure capture, policy, confirmation, expiry, single-use binding, and execution.

Agent boundary

Typed recipient, subject, or operation intent. Schemas containing passwords, credentials, tokens, PIN, OTP, or TOTP are rejected.

Trusted broker boundary

Risk, sensitive fields, presentation, human confirmation, execution, and a sanitized LSFA 0.2 result remain local.

Mechanism

One call, two outcomes — the page never breaks either way.

Every registration path runs through the same check. If the browser can hear it, the agent gets a validated tool. If it can't, your page just renders — a warning in the console, nothing more.

your spec zod + handler defineTool() normalized tool schema + validated fn supportsWebMcp() document.modelContext? yes registerTool() on document.modelContext agent can call it no console.warn() + no-op page renders as usual
Both registerTool() and the Declarative form path run the same supportsWebMcp() branch before touching the browser — the difference is only what happens at the two ends.

Beyond the browser

The same schema, published as a static mcpwasm skill.

toMcpwasmSkillSource() reuses the JSON Schema defineTool() already derived from your Zod spec to emit the registerTool({...}) source mcpwasm expects in a tool.js — no re-declaring it by hand for a second target.

What crosses the boundary

name, description, inputSchema — the exact JSON Schema defineTool() already computed.

What doesn't

Your execute. mcpwasm's handler runs sandboxed in QuickJS-wasm — no DOM, no fetch, no window. You write that logic separately.

import { defineTool, toMcpwasmSkillSource } from 'fastwebmcp';

const tool = defineTool({
  name: 'sum_numbers',
  description: 'Sum two numbers a and b.',
  inputSchema: z.object({ a: z.number(), b: z.number() }),
  execute: async ({ a, b }) => a + b, // browser-only, never auto-translated
});

toMcpwasmSkillSource(tool, {
  handlerBody: 'return args.a + args.b;' // you write this: no DOM in the sandbox
});

No browser required

Test the tools you registered, not a re-implementation of them.

createWebMcpMock() stands in for document.modelContext in Node. invokeTool() runs the exact execute your tool was registered with — Zod parsing included.

import { createWebMcpMock, withMockDocument } from 'fastwebmcp';

const mock = createWebMcpMock();

// Run tests safely without polluting globalThis:
const result = await withMockDocument(mock, async () => {
  registerYourTools();
  return mock.invokeTool('add_todo', { text: 'Buy milk' });
});

mock.hasTool('add_todo'); // true
mock.reset(); // clean up for the next test

Where this actually stands

Verified against a real browser, not just mocks.

WebMCP is still an origin trial. Every claim below was checked, not assumed.

Browser support

Chrome 149+, origin trial. Most visitors won't have it — that's what the fallback is for.

Live-verified

executeTool() ran a real registered handler and updated the DOM in an actual document.modelContext.

Declarative schema

The form → JSON Schema derivation is still unspecified upstream. This library sets the four fixed attributes and stops there — it doesn't guess.

Runtime dependencies

One: zod. The library never imports it at runtime — it only calls methods on the schema you pass in.

This page, dogfooded

Checking…