TypeScript · WebMCP
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.
<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
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.
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);
}
});
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
WebMCP ships two ways to expose a tool. fastwebmcp gives both the same validate-then-run discipline.
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
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
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.
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
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.
name, description, inputSchema — the
exact JSON Schema defineTool() already computed.
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
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
WebMCP is still an origin trial. Every claim below was checked, not assumed.
Chrome 149+, origin trial. Most visitors won't have it — that's what the fallback is for.
executeTool() ran a real registered handler and updated the DOM in an actual document.modelContext.
The form → JSON Schema derivation is still unspecified upstream. This library sets the four fixed attributes and stops there — it doesn't guess.
One: zod. The library never imports it at runtime — it only calls methods on the schema you pass in.
Checking…