fastwebmcp
v0.5.0
Published
FastMCP-style ergonomics for WebMCP: typed builders over the Imperative and Declarative browser APIs.
Downloads
400
Maintainers
Readme
fastwebmcp
FastMCP-style ergonomics for WebMCP: typed
Zod builders over the browser's Imperative and Declarative APIs, with safe no-op +
warning degradation when document.modelContext isn't available (WebMCP is still an
origin trial as of Chrome 149+ — most visitors won't have it yet).
Install
npm install fastwebmcpImperative API
import { z } from 'zod';
import { registerTool } from 'fastwebmcp';
registerTool({
name: 'add_todo',
description: 'Add a todo item to the list.',
inputSchema: z.object({ text: z.string().min(1) }),
execute: async ({ text }) => {
// ... your logic, DOM update, etc.
return `Added: ${text}`;
},
});registerTool validates and normalizes the spec with defineTool (deriving the JSON
Schema from the Zod schema via z.toJSONSchema, and parsing every call's input before
your handler runs), then calls document.modelContext.registerTool(...) if the browser
supports it — falling back to a console.warn no-op otherwise, so your page never
breaks on an unsupported browser.
defineTool also validates name against the WebMCP spec's own charset (1-128 chars,
[A-Za-z0-9_.-]), and warns — never throws — if name/description exceed the length
Chrome's tool security guide
recommends for reliable agent results. Pass annotations: { readOnlyHint, untrustedContentHint }
to flag a tool as side-effect-free or as returning untrusted data — it's forwarded as-is
to document.modelContext.registerTool(). Pass title for an optional human-readable
label; also forwarded as-is.
Declarative API
import { defineDeclarativeTool, respondToAgentSubmit } from 'fastwebmcp';
const form = document.querySelector('form')!;
defineDeclarativeTool(form, {
name: 'submit_support_request',
description: 'Submit a request for support.',
fields: [{ name: 'topic', description: 'Determines what team this routes to.' }],
});
form.addEventListener('submit', (event) => {
event.preventDefault();
const handled = respondToAgentSubmit(event as any, () => ({ status: 'submitted' }));
if (!handled) {
// a human submitted the form -- handle it however you normally would
}
});defineDeclarativeTool sets the toolname/tooldescription/toolautosubmit/
toolparamdescription attributes the WebMCP Declarative API explainer
specifies. The JSON Schema the browser derives from the form's fields is not something
this library computes or validates — that algorithm is still unspecified upstream.
Testing your own tools without a real browser
import {
createWebMcpMock,
withMockDocument,
createMockAgentSubmitEvent,
respondToAgentSubmit,
} from 'fastwebmcp';
const mock = createWebMcpMock();
// Isolated execution without polluting globalThis:
await withMockDocument(mock, async () => {
registerYourTools();
const result = await mock.invokeTool('add_todo', { text: 'Buy milk' });
});
// Check registrations and clean up between tests:
mock.hasTool('add_todo'); // true
mock.reset(); // clears all registered tools for the next test
// Unregistration via AbortSignal (WebMCP spec):
const controller = new AbortController();
registerTool(mySpec, { signal: controller.signal });
controller.abort(); // tool is automatically removed from mock
// Testing declarative form submissions:
const { event, waitForResponse } = createMockAgentSubmitEvent();
respondToAgentSubmit(event, () => ({ status: 'processed' }));
const response = await waitForResponse(); // { status: 'processed' }invokeTool runs the real execute your tool was registered with (Zod parsing
included) — not a reimplementation. withMockDocument isolates globalThis.document
safely with try...finally, and hasTool(name), getTool(name) and reset()
make it easy to assert tool registrations and isolate tests in suites like Jest, Vitest,
or node:test.
Optional LSFA integration
Use fastwebmcp/lsfa when a WebMCP tool needs sensitive local capture or explicit human
confirmation. The agent-facing schema must contain only non-sensitive intent. A trusted
broker supplied by your application owns LSFA policy, risk, secure fields, presentation,
confirmation, expiry, binding, single-use consumption and the side effect itself.
import { z } from 'zod';
import { registerLsfaTool, type LsfaBroker } from 'fastwebmcp/lsfa';
declare const trustedBroker: LsfaBroker; // your LSFA host adapter, not FastWebMCP
registerLsfaTool({
name: 'send_email_securely',
description: 'Ask the trusted local broker to confirm and send an email.',
inputSchema: z.object({ recipient: z.string().email(), subject: z.string() }),
intent: {
operation: 'send_email',
purpose: 'Send only after local confirmation.',
presentation: { mode: 'form', profile: 'confirmation', locale: 'en-US' },
},
broker: trustedBroker,
}, { exposedTo: ['https://trusted-agent.example'] });Schemas containing password, secret, token, credentials, API keys, PIN, OTP or TOTP
fields are rejected recursively. The broker result is also strict and sanitized; captured
values never return through WebMCP. This route is imperative-only, so it never enables
toolautosubmit. Import createLsfaBrokerMock from fastwebmcp/lsfa/testing only in
tests or demos: it records calls and returns explicitly queued results, but performs no
capture, authorization or execution and provides no production security guarantees.
The included LSFA demo is prominently marked as a
simulation. A real broker transport (HTTP loopback, Native Messaging or extension) is
intentionally outside this first contract.
The result shape follows LSFA's published 0.2 schema: only status and operation are
required; request_id, risk, boolean checks, stored_refs (true, false,
present, or absent) and error_code are optional. The presentation validator follows
the 0.3 modes, themes and layout limits. A profile-only object is accepted as an adapter
hint for convenience; the broker must add/choose the canonical mode and validate the
complete LSFA request against its own policy before showing anything.
Framework Integration (React, Next.js, Vue)
WebMCP tools in single-page applications should register when components mount and
clean up when they unmount using an AbortController:
React / Next.js
import { useEffect } from 'react';
import { registerTool, type ToolSpec } from 'fastwebmcp';
import type { ZodType } from 'zod';
export function useWebMcpTool<T extends ZodType>(spec: ToolSpec<T>) {
useEffect(() => {
const controller = new AbortController();
registerTool(spec, { signal: controller.signal });
return () => controller.abort(); // Automatically unregisters on unmount
}, [spec.name]);
}Vue 3 (Composition API)
import { onMounted, onUnmounted } from 'vue';
import { registerTool, type ToolSpec } from 'fastwebmcp';
import type { ZodType } from 'zod';
export function useWebMcpTool<T extends ZodType>(spec: ToolSpec<T>) {
const controller = new AbortController();
onMounted(() => {
registerTool(spec, { signal: controller.signal });
});
onUnmounted(() => {
controller.abort();
});
}Publishing the same schema to mcpwasm
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
});
console.log(
toMcpwasmSkillSource(tool, {
handlerBody: 'return args.a + args.b;', // you write this: no DOM in the sandbox
}),
);toMcpwasmSkillSource reuses the JSON Schema defineTool already derived from your Zod
spec to emit the registerTool({...}) source mcpwasm
expects in a tool.js. This is schema-only, not a runtime bridge: mcpwasm's handler
runs sandboxed inside QuickJS-wasm with no DOM, no fetch, no window — only
registerTool, host.fetchOrigin, and bare ECMAScript — so your execute (which exists
specifically to touch the page) can't run there unmodified. What crosses the boundary is
name/description/inputSchema; the sandboxed handler body is always yours to write
(the function defaults to an explicit TODO stub if you don't supply handlerBody). It
doesn't reimplement mcpwasm's own @rckflr/llms-skills CLI, which stays the tool for
scaffolding, hash-sealing, and publishing.
Examples
Two runnable demo pages, verified against a real document.modelContext, live in
examples/:
npm run build:examples
npx http-server . # or any static file server
# open examples/ux-page/imperative-demo.html and .../declarative-demo.htmlAPI surface
supportsWebMcp() · defineTool(spec) · registerTool(spec, options?) ·
createWebMcpMock() · defineDeclarativeTool(form, spec) · respondToAgentSubmit(event, handler) ·
toMcpwasmSkillSource(tool, options?) · defineLsfaTool(spec) · registerLsfaTool(spec, options?)
Changelog
Every release is documented in CHANGELOG.md, including the RECON
findings and known limits behind each one — not just a list of what shipped.
Development / methodology
This repository is built with KDD (Knowledge-Driven Development):
every function ships with a frozen test oracle authored before the implementation, and
project-level work is tracked as numbered execution contracts under
specs/ with verified reports in docs/reports/. See
AGENTS.md and knowledge/index.md if you're
contributing or want the full methodology reference.
Running the test suite locally (npm test) needs Node.js 23.6+ — it relies on
Node's native .ts execution (node --test tests_ts/**/*.test.ts), which is separate
from the engines.node: ">=18" this package declares for consumers of the published
dist/ (plain compiled JS, no native .ts support needed there).
License
MIT — see LICENSE.
