WebMCP
permdock/webmcp registers browser-exposed WebMCP tools only for actions the current snapshot allows, with hints from action metadata and automatic unregistration when permissions change.
Pinned: Chrome 150 document.modelContext (W3C WebML CG draft)
permdock/webmcp is the client-side twin of permdock/mcp. A page calls registerTools with a permission group and the snapshot-backed PermDock from usePermDock(); the adapter registers one WebMCP tool per allowed action on document.modelContext, sets tool hints from action metadata, and unregisters tools whenever the snapshot changes.
Purpose
WebMCP lets a web page expose tools to in-browser agents through document.modelContext.registerTool() (Chrome docs; navigator.modelContext is deprecated in Chrome 150). Pages are gated by the tools Permissions-Policy, and each tool can declare readOnlyHint and untrustedContentHint. The question a page must answer before registering anything is "which tools may this user trigger?", and that is exactly what the client snapshot holds. Registering tools the user cannot use invites a denied call; registering nothing wastes the capability. permdock/webmcp registers the allowed subset and keeps it in sync.
API
import { registerTools } from "permdock/webmcp";
import { approvalHeaders, usePermDock } from "permdock/react";
function PostTools() {
const permdock = usePermDock();
useEffect(() => {
const controller = new AbortController();
registerTools(document.modelContext, permissions.post, {
permdock,
signal: controller.signal,
handlers: {
read: async ({ input }) => api.posts.get(input.id),
update: async ({ input, token }) =>
api.posts.update(input, { headers: approvalHeaders(token) }),
create: async ({ input }) => api.posts.create(input),
},
});
return () => controller.abort();
}, [permdock]);
return null;
}registerTools(modelContext, permissionGroup, options)walks the group (permissions.postis a resource node; a nested group such aspermissions.billingis also accepted) and registers a tool per action for whichpermdock.can(permission)(collection) or a grant exists (instance actions) in the snapshot.- Tool names are derived from the permission key with dots replaced by underscores (
post.updatebecomespost_update), which fits WebMCP tool-name constraints;titleanddescriptioncome from action metadata when actions are declared as a record. readOnlyHintistruefor actions tagged read-only in metadata (default forreadandlist);untrustedContentHintistruefor tools that return user-generated content, also from metadata.- The resource schema becomes the tool
inputSchemathrough Standard JSON Schema (schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' })) for instance actions and validates their input, whether the group is the root tree, a resource node (permissions.post) or a nested group holding it; collection actions accept no input unlessschemais passed. Aschemaoption overrides the resource schema. A schema without a converter becomes{ type: 'object' }. - Each handler receives
{ input, token }(WebMcpToolCall): the validated arguments with the tenant key bound, and the decision token to send asPermDock-Approvalso the server re-checks the same decision. registerToolstakes one group per call; several calls that share oneAbortControllershare one abort scope.signalunregisters everything on abort; the adapter also aborts and re-registers internally when the snapshot changes (role change, tenant switch throughuseTenant().switchTo,invalidate, SSF event). Re-registration reads the store's current instance, not the object passed aspermdock, so an effect that runs once still sees later snapshots.tenant(optional) registers tools againstpermdock.tenant(id)instead of the active tenant, for a page that shows another organisation's workspace. Tool descriptions include the tenant'smeta.titlefrom the snapshot when one is present so a browser agent can tell two workspaces apart; the tenant id itself is never a tool argument the agent may set (tenancy). A multi-tenant page registers one tool set for the active tenant and re-registers on a switch, never one suffixed set per tenant. A snapshot withsimulated: trueregisters no tools at all.
Request lifecycle
- Mount: the component reads the snapshot-backed
PermDockand callsregisterTools. - Registration: for each action in the group, the adapter evaluates the snapshot and registers only allowed tools, carrying hints and JSON Schema input. An instance action with a
wherecondition registers when a grant exists and has its condition checked against the input at call time. - Agent call: the browser agent invokes a tool. The adapter validates the input against the resource schema (boundary mode), re-checks
permdock.decide(permission, input)against the current snapshot, then calls the app handler. - Server enforcement: the handler calls the app's API, where a server adapter (
permdock/next,permdock/hono) makes the authoritative decision. The client check is a filter for good UX, never the security boundary. - Snapshot change:
usePermDock()re-renders with a new snapshot; the adapter aborts the previous registrations and repeats step 2. - Unmount or navigation: the
AbortSignalfires and all tools are unregistered.
What it validates
- Tool input against the resource's Standard Schema before the handler runs (
validate: 'boundary'): agent-supplied arguments are untrusted. - The
toolsPermissions-Policy:registerToolsis a no-op with a development warning whendocument.modelContextis absent (policy denied, unsupported browser, or the polyfill not loaded). - Snapshot freshness: tools are registered from the snapshot's
status; aserver-onlypermission (closure grant) is not registered until the decision endpoint has answered. - Nothing about the user's identity: the snapshot is issued by the server and is the only source of grants.
- Tenant arguments: if the resource schema carries the first scope's key field (
orgId), the adapter fills it from the active tenant and rejects a differing value supplied by the agent before the handler runs (tenant-mismatch), so a browser agent cannot address another tenant's rows through a tool it holds legitimately.
How denials surface
- Not registered: the default. A tool the user cannot use never appears in the page's tool list, so an agent cannot attempt it.
- Denied at call time (snapshot changed between listing and calling): the handler is not invoked; the adapter returns a tool error result whose text carries the Decision reason and
alternatives, mirroring the MCP refusal shape. approval-required: the adapter does not run the handler; it returns a result asking the agent to obtain user confirmation in the page UI. The app may provide anonApprovalRequiredcallback to open its own confirmation dialog and resolve the call with the approval token.- Server denials: the API answers RFC 9457
application/problem+json; the adapter forwards the problemtitleanddetailto the agent as a tool error.
Example app
apps/examples/webmcp: a Vite React app at http://127.0.0.1:3484/ with post tools registered from a snapshot. The in-memory tool list includes snapshot-allowed tools and excludes post_publish. Serve the page with a Permissions-Policy: tools=* header when using a native or polyfill document.modelContext.
Related standards
- WebMCP:
document.modelContext, Permissions-Policytools, hints,AbortSignal, Puppeteerpage.webmcp, polyfill. - Standard Schema: input schemas via Standard JSON Schema.
- Snapshots: what the client knows and when it changes.
- MCP adapter: the server-side counterpart.
Last updated on
OpenAI Agents SDK
permdock/openai turns PermDock decisions into OpenAI Agents SDK needsApproval predicates, guards tool lists per caller, resolves interruptions against a pluggable ApprovalStore, and binds the replay-safe token to the serialised RunState.
A2A
permdock/a2a emits A2A Agent Cards whose skills carry security requirements derived from permissions and filters the authenticated extended card by the caller's grants.