Inspiration
An AI agent can only press the buttons a website gives it — and those were decided by whoever built the site, months ago. When the button you need isn't one of them, the agent does what agents do: it guesses from the DOM, it scrapes, or it hands you a snippet of Python and hopes you run it.
Adding a real one today means leaving the conversation entirely: write an MCP server, host it, edit a config file, restart the client. Minutes to days of work, and a permanent new attack surface, for a function you might call once.
WebMCP already moved the button surface into the page — where the user's data and session already live, and where a human is already watching. That's enough to close the loop without leaving.
(A note on vocabulary: we say "button" in the interface because that's what it is to the person looking at the screen. The spec calls the same thing a tool, and so does our code.)
What it does
Toolsmith registers five permanent tools on document.modelContext. One of them, forge_tool, takes a name, a JSON Schema and a JavaScript body, and turns them into a real WebMCP tool the same agent can call seconds later — but only after a human grants it.
The loop: the agent calls read_workbench to learn your real column names → calls forge_tool to build the button that isn't there → the page renders it as pending, code on screen and editable, not registered → you press Test run to execute it against your real data before it exists → you press Grant it, which calls registerTool with its own AbortController → the agent presses its new button in the same conversation → Take it back aborts that signal and the button leaves the registry immediately.
The count in the header — "buttons the agent can press" — is the honest answer to what can this agent do right now? And a person decided every one of them.
How WebMCP is used
This is not a wrapper around an existing app; the spec's lifecycle is the product.
- Every built button is registered via
registerTool(tool, { signal })with its ownAbortController. Unregistration isn't a side feature here — aborting that signal is the revocation mechanism the entire safety argument rests on. - The tool surface is genuinely dynamic and authored at runtime by the agent talking to the page.
getTools()and thetoolchangeevent drive the live count in the header. annotations.untrustedContentHinton every built button, because its output is computed by code an agent wrote minutes ago.annotations.readOnlyHinton the two read-only tools.forge_toolaccepts the child tool'sinputSchemaas a JSON string, validates it, and passes it toregisterToolas a real JSON Schema object — schema round-tripping through a tool call.- Native
document.modelContextwhen present; otherwise a vendored@mcp-b/globalpolyfill. The header pill says which one you got.
A button you didn't read is not a button you granted
Self-extension is only acceptable if refusing is as cheap as granting. So: nothing registers before a human approves; the schema and function body stay on screen and editable, with a Test run that executes against real data before registration; each call spawns a fresh Web Worker with fetch, XMLHttpRequest, WebSocket, EventSource, importScripts, storage and navigator deleted from the global scope before the code is compiled (verified: built code sees typeof fetch === "undefined"); a 3-second leash terminates runaway loops; editing a granted button unregisters it and sends it back for re-approval; reloading returns every button to pending, never to granted; and every call, with arguments and results, lands in the activity log.
This is defence in depth, not a security boundary — a Worker shares the origin, and the README says so plainly. It's enough friction that a button written thirty seconds ago can't quietly drain your data while you're still reading the diff.
Challenges we ran into
The hard part wasn't generating code — it was revocation. A page that lets an agent extend itself is only defensible if taking the capability back is instant and obvious. WebMCP's AbortSignal-based registration turned out to be exactly the right primitive: one controller per button made grant, edit, take-back and page-reload all collapse into the same mechanism.
The second hard part was language. Our first build talked about "dynamic tool lifecycles," which is accurate and means nothing to anyone. Calling it a button — something a person already knows they can be given, and can have taken away — made the same code explain itself.
What we learned
That the interesting part of WebMCP isn't that a page can expose tools. It's that the set of tools is mutable at runtime, and that mutation can be put in a human's hands. Most of the spec's power lives in the { signal } argument that's easy to skim past.
What's next for Toolsmith
Cross-origin granting via exposedTo, so one page can hand a vetted button to another. Signed button sets, so a team can publish a reviewed collection instead of each person approving their own. And an output schema, so built buttons can declare structuredContent rather than text.
Built With
- css
- github
- html
- javascript
- json-schema
- web-workers
- webmcp
Log in or sign up for Devpost to join the conversation.