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 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));
});

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 } from 'fastwebmcp';

const mock = createWebMcpMock();
globalThis.document = mock.document;

registerYourTools();

const result = await mock.invokeTool('add_todo', { text: 'Buy milk' });

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…