Editor tools for browser agents (WebMCP)
Let the agent built into the user's browser build, save and run the open workflow — reads run at once, changes wait for a click.
These are editor tools, not workflow tools. They let an agent that lives in the browser — Chrome's, Edge's, Brave Leo, ChatGPT Desktop — edit the workflow that is open on the page. They have nothing to do with the tools a running workflow hands to an LLM node; that is a separate feature with its own vocabulary.
WebMCP is a W3C Community Group
proposal: a page registers typed tools on document.modelContext, and a
browser-resident agent discovers and calls them. FlowDrop's editor already has a typed
command layer — add a node, connect two ports, set a config value, list, inspect,
undo — so @flowdrop/flowdrop/webmcp registers those commands as tools, one per
command, each with a JSON Schema. The agent gets structured arguments, structured
errors, and argument completion — no text DSL to guess at.
The same tools power the built-in AI Assistant. Since 2.8.0 the chat panel runs the model's tool calls through the very runtime described on this page — the same validation, the same approval dialog, the same one-transaction-one-undo-step — so a site user who never enables a browser agent gets the same abilities from the panel.
What an agent can do with them
An agent that only has the tools — no source, no admin access, no drush — can take a workflow from empty to a completed run. The tools are designed around that path, and every mutating tool's description ends with it:
- Read before you write.
list_types(orsearch_typeswith a substring) says which node types exist in this editor; a type not in that list is unavailable.describe_typethen says everything about one type before it is used: input and output ports with data type and required flag, every config key with its type, enum, default and description, and — when the host publishes them — whether a run pauses for a person at this node (confirmation), what the user may do with it (can), and host-written guidance (agent). - Build in one batch.
batchruns several commands as one transaction and one approval: add the nodes,connectthe ports,set_configthe keys. All succeed or none apply, and one undo reverts them all. - Check what you built.
list_nodes,list_edges,infofor a node instance,get_configfor its current values with the schema that explains them. - Save. Nothing an agent changes is persisted until
saveruns or the person clicks Save. - Run and watch.
runstarts the saved workflow with optional inputs for its interface ports and returns arunId;run_statuspolls it. A run that pauses for a person comes back asPENDINGwith the node and message it waits on — the agent tells the user, and the user acts in the editor. No tool answers an interrupt.
Every one of those facts travels in the tool descriptions and tool results. That is
the entire interface the agent has, so when a call fails, the error teaches: an
unknown node lists the workflow's nodes, an unknown type names near misses and points
at list_types, an unknown port lists the node's ports on both sides, an unknown
config key lists the keys. set_config on a key the type's schema does not declare is
still stored — hosts keep config open — but carries an unknown_key warning naming the
declared keys; under strict: true it fails instead.
The tools read only what the editor has already fetched. The node type catalog and the open workflow are the two payloads; there are no per-tool requests and no discovery call. Whatever the agent should know about a node type, the host puts in the catalog payload (see Host-published fields). Freshness is the editor's: when it refetches, the tools see the new list.
Setup
Svelte host
<script lang="ts">
import { onMount } from 'svelte';
import { App, createFlowDropInstance } from '@flowdrop/flowdrop/editor';
import { attachWebMCP } from '@flowdrop/flowdrop/webmcp';
const fd = createFlowDropInstance();
onMount(() => {
const handle = attachWebMCP(fd, { nodeTypes });
return () => handle?.detach();
});
</script>
<App instance={fd} workflow={workflow} nodes={nodeTypes} />attachWebMCP returns null — silently — when the browser has no WebMCP runtime,
so you can call it unconditionally. It detaches itself when the instance is destroyed
(through instance.onDestroy, which your own integrations can use too).
Registration is asynchronous in the spec. handle.tools lists the tools the runtime
has accepted so far; await handle.ready if you need the final list. A tool the
runtime refuses is warned about once and never listed.
Every tool description carries the workflow's name so an agent facing two editors can
tell them apart. The name is read when you attach, so attach once the workflow is
loaded — the App component loads it on mount — and expect a later rename not to
show until the next attach.
Vanilla, Drupal, any framework
import { mountFlowDropApp } from '@flowdrop/flowdrop/editor';
const app = await mountFlowDropApp(el, {
endpointConfig: createEndpointConfig('/api/flowdrop'),
webmcp: true
});
app.webmcp?.tools; // ['flowdrop_add_node', 'flowdrop_connect', …] or undefinedThe adapter is imported on demand, so the core bundle is unchanged when the option is
off, and it attaches in the background once the workflow is loaded, so mounting is not
delayed and no extra request is made — the tools read the node types the editor itself
fetched. app.webmcp is therefore undefined for a moment after mount even in a
browser that has the API. Pass an object instead of true to set the options below;
onSave is supplied for you on this path, onRun and onRunStatus are yours to add.
Options
| Option | Default | What it does |
|---|---|---|
nodeTypes | (the instance's) | The node type definitions the tools resolve types against. An array or a getter. Defaults to what the editor fetched. |
prefix | 'flowdrop' | Tool names are ${prefix}_${verb}. Two editors on one page need two prefixes; a second attach with the same prefix throws. |
approval | 'confirm' | What happens before a change runs. See below. |
rememberEdits | true | Offer "apply further edits without asking" in the confirm dialog. Edits only; save and run always ask. |
onUIAction | (none) | Handler for the view tool (select node, open config, zoom, pan). Without it the view tool is not registered. |
onSave | (none) | Persist the workflow. When supplied, a save tool is registered, gated like a change. mountFlowDropApp supplies it automatically. May return an envelope. |
onRun | (none) | Start a run with the given inputs; answers an envelope with data.runId. When supplied, a run tool is registered, gated like save. |
onRunStatus | (none) | Report a run's status as an envelope. Registers run_status (read-only) when onRun is also given. |
container | document.body | Where the confirm dialog mounts. |
messages | English | Strings for the confirm dialog, as a partial override of defaultMessages.webmcp (or a getter for one) — the same shape as the messages prop. |
Host hooks and the result envelope
onSave, onRun and onRunStatus are how the tools reach your server, and they
answer in one shape:
interface HostEnvelope<T> {
ok: boolean;
code?: 'FORBIDDEN' | 'UNAVAILABLE' | 'NEEDS_APPROVAL' | 'INVALID' | 'CONFLICT' | 'PENDING' | 'NOT_FOUND' | string;
message?: string;
data?: T;
can?: Record<string, boolean>;
}The tool relays code, message, data and can to the agent untouched. The codes
mean: FORBIDDEN — the server refused, tell the user; UNAVAILABLE — nothing to act on
yet (an unsaved workflow, say); NEEDS_APPROVAL — a person must approve in the UI
first; INVALID — validation failed, the message says where; CONFLICT — the server
copy changed since it was loaded, reload; PENDING — a run is paused for a person;
NOT_FOUND — the run id is unknown. They line up with the FlowDrop Drupal module's
error_code vocabulary where the two meet.
attachWebMCP(fd, {
onRun: async (inputs) => {
const res = await fetch(`/api/flowdrop/workflow/${id}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ inputs })
});
if (res.status === 403) return { ok: false, code: 'FORBIDDEN', message: 'Not permitted' };
const { data } = await res.json();
return { ok: true, data: { runId: String(data.pipelineId), status: data.status } };
},
onRunStatus: async (runId) => {
const { data } = await (await fetch(`/api/flowdrop/pipeline/${runId}/status`)).json();
return {
ok: true,
data: {
runId,
status: data.status, // pending | running | paused | completed | failed | cancelled
pending: data.pendingInterrupt
? { interruptId: data.pendingInterrupt.id, type: data.pendingInterrupt.type }
: null
}
};
}
});onRunStatus may return status: 'paused' with a pending block naming the
nodeId and message the run waits on; the run_status tool turns that into an
ok result with code: 'PENDING' and a message telling the agent a person must act.
Once a run is completed or failed, put its result in data.outputs (host-shaped)
and the agent reads it from the same call.
onSave may still resolve to nothing (saved) or throw. A thrown error that is a 409
or carries errorData.error_code === 'CONFLICT' — what the library's ApiError does
when the FlowDrop Drupal module refuses a stale write — is relayed as CONFLICT with
a reload hint; anything else is SAVE_FAILED.
Host-published fields
The server stays the authority: save and run endpoints refuse what the user may
not do, and the tools only pre-empt. Two optional fields on the workflow payload and
three on each node type let a host say so up front:
| Where | Field | What the tools do with it |
|---|---|---|
| Workflow | can | { save: boolean, run: boolean }. save/run answer FORBIDDEN before opening a dialog when the key is false; absent means "ask the server". |
| Workflow | revision | The revision the editor loaded. The save path sends it back; a server that checks it refuses a stale write with CONFLICT, and the editor takes the new value from the response. |
| Node type | confirmation | { policy: 'ask' | 'skip', source?, authorControls?, dynamicControls? }. Shown by describe_type so the agent can warn the user a run will pause here. |
| Node type | can | { add: boolean, … }. Passed through by describe_type. |
| Node type | agent | { usage?, examples?, pairsWith? } — guidance you write for agents. Passed through verbatim; the library never interprets it. |
The FlowDrop Drupal module publishes all five. Any host can, by adding the keys to its
NodeMetadata and Workflow payloads; no library release is needed to improve what an
agent reads about a node.
The approval gate
WebMCP's permission model decides which origins may see a page's tools. It says nothing about whether a given call may change the user's document, and any agent or extension on the page can call a registered tool. So consent is the editor's job:
Calls fall into three classes:
- Reads run at once:
list_types,search_types,describe_type,list_nodes,list_edges,info,get_config,run_status. They carryreadOnlyHint: true. - View changes run at once too.
view— select a node, open its config panel, fit, zoom, pan — changes what the editor shows and nothing in the workflow; there is nothing to undo, and a dialog for it would only teach people to click Apply unread. - Changes wait. A dialog inside the page lists exactly what the agent wants to run —
"Add node http_request", "Connect http_request.1:body → html_to_markdown.1:html" — and
nothing touches the workflow until the person at the keyboard clicks Apply. Reject
returns a
REJECTEDerror to the agent and changes nothing.undoandredoare changes. Abatchasks once, for the whole batch, and above the line list shows a summary of what changes: "4 changes — adds 2 nodes, connects 1 edge, sets 1 config key."saveandrunask too, with their own wording, and carryconsequentialHint: true. - "Don't ask again" covers edits only. The dialog offers Apply further edits from
any agent on this page without asking, until this page is closed — the gate cannot
tell one agent from another, so the wording says what the tick does. Ticked and approved, later edits
run without the dialog until the adapter detaches;
saveandrunalways ask and never offer the box.rememberEdits: falseremoves it. - One decision at a time. A second mutating call while a dialog is open is
refused with
BUSY; dialogs never stack. - Every call is one transaction and one undo step, exactly like an approved batch
in the AI Assistant panel. A
batchwhose third command fails leaves the workflow as it was and reports which command failed and why.
approval takes three shapes:
attachWebMCP(fd, { nodeTypes, approval: 'confirm' }); // default: the dialog
attachWebMCP(fd, { nodeTypes, approval: 'auto' }); // no dialog — see below
attachWebMCP(fd, { nodeTypes, approval: async (commands, { tool }) => myOwnGate(commands, tool) });A custom approval function receives the commands about to run and a second argument,
{ tool }, naming the tool being called (add_node, batch, save, run, …). For
save and run there are no commands to describe — commands is an empty array.
'auto' removes the only consent step there is. Use it when the host already
trusts every agent that can reach the page — a kiosk, a test harness — and nowhere
else. It is not a convenience setting.
The layout opt-out in Settings (AI layout changes) applies here too: with it off,
auto_layout and beautify_layout are skipped with the same wording the chat panel
uses, and the rest of a batch still applies.
What is exposed
| Tool | Gated | Notes |
|---|---|---|
list_types, search_types | no | The typeIds add_node accepts, with name, category, description, tags. A type not listed is unavailable. |
describe_type | no | Ports, config schema with enums and defaults, and the host's confirmation / can / agent. Call before add_node and set_config. |
list_nodes, list_edges | no | Short ids (http_request.1) everywhere. |
info | no | Ports, current config, connected edges of one node instance. |
get_config | no | One value with its schema entry, or every value plus the whole schema when key is omitted. |
add_node, delete_node | yes | Unknown type ids fail and name near misses. |
rename_node, move_node | yes | |
swap_node | yes | Reports which edges and config were dropped. |
set_config | yes | Strings stay strings; numbers, booleans, objects stay typed. Warns (or fails under strict) on enum, type and unknown-key problems. |
connect, disconnect_ports, disconnect_node | yes | An unknown port lists the ports on both sides. |
undo, redo | yes | A save cannot be undone. |
auto_layout, beautify_layout | yes | Skipped when layout changes are off in Settings. |
view | no | Only with onUIAction. Select, open config, fit, zoom, pan — changes the view, not the workflow. |
save | yes | Only with onSave, supplied automatically by mountFlowDropApp. FORBIDDEN / CONFLICT / INVALID from the host. |
run | yes | Only with onRun. Starts the saved workflow with optional inputs; returns runId. |
run_status | no | Only with onRun and onRunStatus. PENDING for a paused run; outputs once completed. |
batch | yes | Several commands, one transaction, one approval, one undo. |
Not exposed, on purpose: clear. An agent can delete nodes one at a time, and
each deletion is gated; wiping the whole workflow in one call is not something a
browser agent should be able to ask for. help is not exposed either — the schemas
are the help. And no tool answers an interrupt: a paused run is reported, not resolved.
Browser support
From the spec repository's implementation-status.md, read on 2026-09-02:
| Client | Status |
|---|---|
| Chrome 149 | Origin trial |
| Edge 150 | Origin trial |
| Brave Leo | Experimental |
| ChatGPT Desktop | Supported |
| Firefox, Safari | Under standards review |
The API is a moving target: it has already moved from navigator.modelContext to
document.modelContext, and unregisterTool has been replaced by an AbortSignal.
The adapter feature-detects against the minimal shape it needs and keeps every
runtime-touching line in one file; nothing in the core library depends on it. For local
development, Chrome exposes the API behind chrome://flags/#enable-webmcp-testing;
note that Chrome 151's executeTool takes the arguments as a JSON string.
A desktop MCP client instead of a browser agent
Until browsers ship the API, the same tools can be reached from a desktop MCP client
(Claude Code, Claude Desktop, Cursor, …) through a relay: the page registers its tools
with a small widget that talks to a localhost MCP server the user runs and authorises
with a one-time token. @flowdrop/flowdrop/webmcp ships an adapter for the widget shape
of @jason.today/webmcp — install it before
the editor mounts and the adapter treats it as the runtime:
import { installBridgedModelContext } from '@flowdrop/flowdrop/webmcp';
const widget = new WebMCP(); // the bridge's page-side script, loaded by the host
installBridgedModelContext(widget); // no-op when a real document.modelContext exists
const app = await mountFlowDropApp(el, { endpointConfig, webmcp: true });On the client side, npx -y @jason.today/webmcp@latest --mcp is the MCP server; the
user asks their client for a token and pastes it into the widget. Tools arrive prefixed
with the site host (flowdrop_example_com-flowdrop_describe_type). Nothing about the
gate changes: reads run, every change still asks the person in the page, and save and
run are still checked by the server. Three caveats: the widget cannot unregister a tool, so
after the editor is destroyed its tools answer a structured UNAVAILABLE until the page
unloads; the relay's MCP server times a tool call out after 30 seconds, so a confirm dialog
left open longer returns a timeout to the agent while the dialog stays up (approving it
then still applies the change); and this is a developer facility — each user needs the local server and a token
per session — not a way to give ordinary site users an agent.
Drupal. The FlowDrop module exposes both switches under Browser agents at
/admin/flowdrop/config/ui-settings: Expose editor tools to browser agents
(webmcp_tools, on by default — the site-wide off-switch) and Load the desktop bridge
widget (webmcp_bridge, off by default, only meaningful with tools on). The widget
ships vendored with the module; no CDN request is made.
Security notes
- Untrusted text in, gated changes out. Read tools return node labels and config values verbatim. An agent reading a workflow someone else authored is reading untrusted text, and prompt injection through it is possible. The gate is what contains it: injected instructions can make the agent ask for a change, not make one — and never make a save or a run without a click.
- Denials stay coarse. A
FORBIDDENsays "not permitted" and nothing about the rule; the tools never list what the user cannot see. - Same origin, top level. The adapter never sets
exposedToand FlowDrop does not addallow="tools"anywhere. The tools are visible to agents on this origin only. - One prefix per editor. Registering a second editor under the same prefix throws rather than silently shadowing the first.
Testing without a browser agent
CI has no agent and neither does most laptops yet. createFakeModelContext() returns
a runtime that records registrations and lets you invoke a tool by name:
import { attachWebMCP, createFakeModelContext } from '@flowdrop/flowdrop/webmcp';
const runtime = createFakeModelContext();
attachWebMCP(fd, { nodeTypes, approval: 'auto', modelContext: runtime });
const type = await runtime.call('flowdrop_describe_type', { nodeTypeId: 'http_request' });
// { ok: true, data: { typeId, inputs: [...], outputs: [...], config: [{ key, type, enum, default }], … } }
const out = await runtime.call('flowdrop_add_node', { nodeTypeId: 'http_request' });
// { ok: true, message: '…', data: { nodeId: 'http_request.1', … } }The library's Storybook has a WebMCP story with a fake-agent side panel that does the same thing interactively, dialog included.