@nxisai/webmcp
v0.2.0
Published
TypeScript polyfill for the WebMCP API (document.modelContext / navigator.modelContext)
Maintainers
Readme
@nxisai/webmcp
A TypeScript polyfill for the WebMCP API — the proposed web standard (webmachinelearning/webmcp) that lets web pages expose Model Context Protocol–style tools to AI agents via document.modelContext.
It is built to the WebMCP spec: each method follows the spec's algorithm, in the spec's order, down to which error a call meets first. npm run conformance checks every behaviour against the spec, and shows beside it what a real Chrome does. Use it to:
- write against
document.modelContexttoday without feature-gating your code, - test tool registration and execution in unit tests / Node / browsers without WebMCP,
- let in-page or extension-based agents discover and call your tools via
getTools()/executeTool().
When the browser has its own document.modelContext, the polyfill steps aside. Browsers are still converging on the spec — see Browsers.
Install
npm install @nxisai/webmcpUsage
Auto-install (recommended)
import "@nxisai/webmcp/auto";
const controller = new AbortController();
await document.modelContext.registerTool(
{
name: "add-todo",
description: "Add a new item to the user's active todo list",
inputSchema: {
type: "object",
properties: {
text: { type: "string", description: "The text content of the todo item" },
},
required: ["text"],
},
async execute({ text }, { signal }) {
await addTodoItemToCollection(text, { signal });
return { content: [{ type: "text", text: `Added todo item: "${text}".` }] };
},
},
{ signal: controller.signal }
);
// Later: unregister by aborting.
controller.abort();registerTool returns a promise, and every failure arrives through it — none are thrown.
Explicit install
import { installWebMCPPolyfill, isWebMCPSupported } from "@nxisai/webmcp/polyfill";
if (!isWebMCPSupported()) {
installWebMCPPolyfill();
}modelContext is a secure-context API: in an insecure context nothing is installed and installWebMCPPolyfill() returns undefined.
Agent side: discovery and execution
const tools = await document.modelContext.getTools();
// [{ annotations?, description, inputSchema?, name, origin, title, window }], sorted by name
document.modelContext.addEventListener("toolchange", async () => {
console.log("toolset changed:", await document.modelContext.getTools());
});
// Pass the tool from getTools(), and the input as an object.
const addTodo = tools.find((t) => t.name === "add-todo");
const result = await document.modelContext.executeTool(addTodo, { text: "buy milk" });
// => '{"content":[{"type":"text","text":"Added todo item: \"buy milk\"."}]}' — always JSON
JSON.parse(result);API
| Member | Description |
| --- | --- |
| registerTool(tool, { signal?, exposedTo? }) | Register one tool. Resolves once registered and announced; abort the signal to unregister. |
| getTools({ fromOrigins? }) | The registered tools, as RegisteredTools, sorted by name. |
| executeTool(tool, inputObject, { signal? }) | Run a tool. Resolves with its result as a JSON string. |
| toolchange / ontoolchange | The set of tools changed. Once per registration or unregistration. |
| toolactivated / ontoolactivated | A tool's execution began. A ToolActivatedEvent, with toolName. |
| toolcancel / ontoolcancel | A tool's execution was cancelled. A ToolCancelEvent, with toolName. |
What each does with what it is given, per the spec:
registerTool
name,descriptionandexecuteare required, andinputSchemamust be an object: otherwiseTypeError.- The name must be 1–128 characters of ASCII letters, digits,
_,-and., and not already registered; the description must not be empty: otherwiseInvalidStateError. - A schema JSON cannot represent (a cycle) rejects with the serializer's error.
- An already-aborted signal rejects with its reason. Aborting later unregisters the tool, fires
toolchange, and rejects the promise if it is still pending. - An
exposedToorigin that doesn't parse or isn't potentially trustworthy (https, or localhost) is aSecurityError. - The promise resolves after
toolchangehas fired.
getTools
- Each tool has
name,title(""when none was given),description,originandwindow, plusinputSchemaandannotationswhen the tool declared them. inputSchemais a copy through JSON: what JSON can't carry (anundefineddefault, say) is not there.- Annotations are the spec's four hints —
readOnlyHint,untrustedContentHint,consequentialHint,debugging— eachfalseunless set. Anything else a tool declares is dropped.
executeTool
toolmust be aRegisteredTool— asgetTools()returns it; a name is aTypeError. An unparseable or opaqueoriginis aNotSupportedError.inputObjectis required and must be an object; omitting it, or passing a string,nullor a number, is aTypeError. The tool receives a fresh copy, through JSON — so aDatearrives as a string, and aDateas the whole input is refused withUnknownError, its JSON not being an object.- Input is not checked against
inputSchema; the agent is responsible for sending what the schema asks for. executeis called as a WebIDL callback:thisisundefined, and its second argument is{ signal }, aborted if the caller cancels.- The result is serialized as JSON — a string result arrives quoted (
"done"→'"done"'). A tool that throws, or returns what JSON can't represent (undefined, a function, a cycle), rejects withUnknownError; so does a tool that isn't registered. - An already-aborted signal rejects with its reason. Aborting mid-run rejects with the reason, aborts the tool's signal, and fires
toolcancel; a result that arrives afterwards is ignored.
validateAgainstSchema() — a lightweight JSON Schema checker — is exported for agents that want to check their input before calling.
What a polyfill can't do
- Connect you to a real agent. Native WebMCP lets the browser's agent call your tools. A polyfill can only make the API available — something in the page (a script, test harness, or extension content script) still has to call
getTools()/executeTool(). - Other documents' tools. The spec's
getTools()gathers tools from every frame in the page, andexecuteTool()runs them wherever they live. A script can't reach into other documents' registries, so here each document sees its own tools; a tool from another window rejects withUnknownError.exposedToandfromOriginsare validated but otherwise have nothing to act on. - Browser-level checks. "Fully active" is always true for a running script. The
toolspermissions policy is honoured only where the browser knows the feature. The origin-keyed agent cluster check useswindow.originAgentClusterwhere the browser exposes it. - The declarative (HTML form) API. Only the imperative API is polyfilled; the spec hasn't defined the declarative one yet.
- No document at all. In Node there is no document origin;
http://localhoststands in, so tools can run in tests.
Browsers
Chrome ships WebMCP ahead of the spec text in places, and npm run conformance lists where. Measured with this repo's scenarios:
| | Spec (this polyfill) | Chrome 155 (Beta) | Chrome 156 (Canary) |
| --- | --- | --- | --- |
| A string result | '"plain"' (JSON) | 'plain' | 'plain' |
| A tool that returns nothing | rejects UnknownError | 'undefined' | 'undefined' |
| executeTool(tool) with no input | rejects TypeError | runs with {} | runs with {} |
| debugging annotation | reported | dropped | reported |
| toolactivated / toolcancel | fired | not fired | not fired |
| ontoolactivated / ontoolcancel | present | absent | absent |
Everything else the suite covers — registration and its errors, name rules, getTools()'s shape and order, input refusals, cancellation — Chrome 155 does as the spec says. Chrome 153 (stable) predates the spec further: it takes the input as a JSON string, refusing an object, and reports inputSchema as a string.
Code that runs against both is straightforward: always pass an input object, return an object (not a bare string or nothing) from execute, and parse executeTool's result with JSON.parse, falling back to the raw string.
Migrating from 0.1
| 0.1 | 0.2 |
| --- | --- |
| provideContext({ tools }) | Removed. Call registerTool for each tool; abort a signal to remove one. |
| unregisterTool(name), clearContext() | Removed. Unregister by aborting the signal passed to registerTool. |
| executeTool("name", JSON.stringify(input)) | executeTool(toolFromGetTools, input) — a RegisteredTool and an input object. |
| executeTool resolved with { content: [...] } | Resolves with the result as JSON; JSON.parse it. |
| Input could be omitted | Required. Pass {} for a tool that takes none. |
| A throwing tool resolved with { isError: true } | Rejects with UnknownError. Return { isError: true, … } yourself to report a failure the agent should read. |
| A tool returning undefined resolved | Rejects with UnknownError. Return something. |
| Unknown tool → NotFoundError | UnknownError. |
| Schema-invalid input → TypeError | Not validated; the tool gets the input as sent. |
| Re-registering a name replaced the tool | Rejects with InvalidStateError. |
| Any tool name | 1–128 of [A-Za-z0-9_.-]. |
| registerTool threw synchronously | Returns a promise that rejects. |
| execute(input, client) | execute(input, { signal }), called with this undefined. |
| navigator.modelContext alias on by default | Off by default; not in the spec. |
| ModelContextToolInfo, ModelContextToolAnnotations, ModelContextClient | RegisteredTool, ToolAnnotations, ToolExecuteCallbackOptions (old names kept as deprecated aliases). |
Development
npm install
npm test # vitest
npm run build # emits dist/
npm run conformance # every behaviour against the spec, with Chrome beside itnpm run conformance runs the same scenarios through the polyfill and the browser's native document.modelContext, and compares both with what the spec's algorithms produce. It fails if the polyfill departs from the spec; where Chrome does, it says so without failing. It uses Chrome Beta by default. To test another Chrome, set CHROME_CHANNEL (for example chrome-canary) or CHROME_PATH. To test another implementation, set POLYFILL=/path/to/module.js#ExportName.
