Skip to content

feat(plugins-webmcp): a WebMCP tool surface for OpenLeaf editors - #252

Open
PeytonNowlin wants to merge 35 commits into
mainfrom
feat/webmcp-tool-surface
Open

feat(plugins-webmcp): a WebMCP tool surface for OpenLeaf editors#252
PeytonNowlin wants to merge 35 commits into
mainfrom
feat/webmcp-tool-surface

Conversation

@PeytonNowlin

@PeytonNowlin PeytonNowlin commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Implements the WebMCP tool surface spec.

An opt-in plugin package that registers a WebMCP tool set for the OpenLeaf
editors on a page. An agent lists the editors, asks each one what it can hold
and what it can do, locates text by searching rather than by guessing at
positions, and makes changes through the same commands and the same sanitize
policy a human's typing goes through. The editor gains no new nodes, no new
marks, no new buttons, and no new CSS; a deployment that does not install the
plugin is byte-for-byte the deployment it is today.

Tickets

The eight tools

openleaf_list_editors, openleaf_get_capabilities, openleaf_get_document,
openleaf_find_text, openleaf_get_structure, openleaf_replace_at,
openleaf_insert_html, openleaf_apply_command.

Every tool is gated by an optional integrator predicate, carries readOnlyHint,
and marks untrustedContentHint wherever it returns document content.

Departures from the spec, and why

The browser API is document.modelContext / navigator.modelContext, not
document.agent / navigator.agent.
#241's spike table names the latter. A
re-probe of the Chrome for Testing 151 that this repo's Playwright bundles, with
--enable-blink-features=WebMCP, found it wrong — and found that the abort
signal works only as registerTool(descriptor, { signal }), the second
argument. A signal inside the descriptor is silently ignored, so building to
the spec as written would have shipped a registration with no teardown at all.
Everything that names the API lives in src/agent.ts; the next rename is one
file.

Agent HTML is sanitized with normalizePastedHtml from
@openleaf-editor/paste, not sanitizeHtml from @openleaf-editor/sanitize.

normalizePastedHtml is what the editor's own paste path actually runs, so it
is literally "the same policy paste uses". @openleaf-editor/sanitize is a
server-side package whose sub-path imports the shared-runtime bundler cannot
resolve; using it would have meant changing the core bundle, which the spec
forbids.

The demo page is untouched#241 lists it as out of scope. The bundle is
built and budgeted but not shown there.

Review findings fixed on the branch

A two-axis review (repo standards, and the spec's 37 user stories plus the nine
acceptance checklists) ran before this left draft. The one that mattered:

Agent HTML could steer the sanitizer into its lax path. normalizePastedHtml
dispatches on detectSource(), and looksLikeOpenLeaf is /\bdata-pm-slice\s*=/
— an attribute the agent controls in its own argument. Setting it selected the
internal-clipboard normalizer, which keeps inline styles, so
<div style="position:fixed"> survived a write that was supposed to strip it.
That defeats user story 21 outright, and the e2e test guarding it passed only
because its fixture omitted eleven characters. Agent HTML is foreign input and
now always takes the foreign-input path. The other three source signals were
checked and left alone: they can only select a stricter normalizer.

Also fixed: a third-party command could dispatch through the live EditorView
behind the "exactly one transaction" capture; applyCommand re-declared the
shared handle description and its copy wrongly told agents outline handles were
not accepted; registerAgentPermission was last-writer-wins, so any page script
could clear the integrator's policy (now set-once and non-clearing); six
duplicated argument guards; findText alone omitted id from its payload; and
two comments describing a 1.3 KB bundle that is now 7.2 KB.

Second review round

Three more, all confirmed and fixed:

An agent could undo the author's work through a handle. undo and redo
are registered, are first on the default bar, and have a plain command, so
every guard in openleaf_apply_command passed them through — and they are the
one pair that ignores the selection, acting on the last history event wherever
in the document it happened. Reproduced before fixing: the tool answered
{"ok":true,"command":"undo"} with an edit made in another paragraph gone.
ToolbarItemSpec now carries scope?: 'selection' | 'document' (default
selection); undo and redo declare document, and the tool refuses one
with unsupported-command. The toolbar does not read the field, so nothing
about the bar changes, and there is still no list of command names in the tool.

The permission predicate was read for truthiness, not for true. The
documented contract is that only true allows a call. An async predicate
answers with a Promise and one ending in a session lookup answers with an
object; both are truthy, so both silently authorized reads and writes on a page
whose host thought it had installed a veto. The answer is now typed unknown
and compared with ===.

A call whose arguments were not an object threw. Nothing validates a call
against the schema a tool published, so a top-level null or a number reached
execute and threw at the first property read — reaching the agent as a
rejected call with no shape to it. Normalized once in gated, the seam every
descriptor is composed through, so every tool answers invalid-argument
instead. Asserted over the whole set rather than a list of names.

A fourth finding, a commit missing a sign-off trailer, named an object that is
not on this PR; all 33 commits carry the trailer and the Sign-off job is green.

Documentation

packages/plugins-webmcp/README.md (new), root README.md,
packages/element/README.md, SECURITY.md (agent tools are a new caller, and
the permission predicate), docs/integrating-openleaf.md,
docs/authoring-plugins.md §1, §3.4 (the new scope field) and the §4.5 size
table, docs/releasing.md and AGENTS.md (fifteen → sixteen packages), and
CHANGELOG.md under ## Unreleased.

The second review round also touched SECURITY.md, docs/integrating-openleaf.md
and the package README (the predicate is compared with ===, and what to do when
the decision needs an await), the README's error-token list (a malformed call is
an invalid-argument result, never a throw) and its openleaf_apply_command
section (a command acting on document history cannot be handle-scoped), plus
src/result.ts on what unsupported-command now covers.

Dependencies

No new third-party dependencies. Three existing workspace/peer packages were
added to the new manifest: @openleaf-editor/ui (to read which commands a
deployment installed), prosemirror-model and prosemirror-history (for
closeHistory). All three are already on the shared runtime, so the script-tag
bundle ships no extra copy.

No translation obligation

The plugin contributes no user-visible strings. Tool names and descriptions are
agent-facing and stay in English, per #241.

Verification

pnpm typecheck, pnpm test (1884), node scripts/check-docs.mjs,
node scripts/bundle-budgets.mjs (openleaf-webmcp.min.js 7.4 / 8 KB gzipped —
the refusal text the second round added; the documented figure moved with it),
pnpm test:e2e (1269 passed on all three engines) and
node scripts/verify.mjs --quick all pass. The two remaining e2e failures,
[firefox] placeholder.spec.ts and [webkit] demo.spec.ts › promo video, fail
identically on fc30bbb and on main and are untouched by this branch;
[firefox] resilience.spec.ts flakes under full parallel load and passes on its
own.

Closes #241
Closes #242
Closes #243
Closes #244
Closes #245
Closes #246
Closes #247
Closes #248
Closes #249
Closes #250

🤖 Generated with Claude Code

Placeholder so the tracking PR for #241 exists while the ticket work
lands on it.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An agent arriving on a page that has OpenLeaf editors can now ask which
editors are there and get back a stable identifier for each one. When an
editor is removed from the page its identifier stops being offered; an
editor mounted after the bundle loaded starts being offered.

This is the tracer bullet for the whole feature: the thinnest complete
path through every layer, and the shape the remaining tools are built on.

Registration is page-global and made once, which is forced rather than
chosen. A probe against Chrome for Testing 151 with
`--enable-blink-features=WebMCP` found that a second `registerTool` under
a name already taken rejects with `InvalidStateError: Duplicate tool
name`, so a tool set per editor would fail on the second editor -- and a
page with several editors is the normal case here. Each editor instead
adds itself to a page-global register through the editor plugin's own
per-view lifecycle. That register is keyed on the host element rather
than held in the plugin view's closure, because `state.reconfigure`
destroys and recreates every plugin view: an identifier in the closure
would be reassigned every time any other plugin registers, and the id an
agent was handed one call ago would name nothing.

The same probe corrected two things #241 records. The object is
`document.modelContext`, falling back to the deprecated
`navigator.modelContext` -- not `agent`. And the abort signal works only
as `{ signal }` in `registerTool`'s second argument; inside the
descriptor it is silently ignored, which would have left a registration
with no teardown at all, since the API exposes no `unregisterTool`, no
bulk replace and no clear.

The tool set is a plain value -- names, titles, descriptions, input
schemas, annotations and executable handlers -- and installing is a thin
wrapper that hands it to the browser. That is the seam: the flag-free
suite calls the handlers directly in all three engines, so it does not
break the next time a young API is renamed, and one Chromium-only spec in
a new `chromium-webmcp` Playwright project proves the real registration
path against the browser's own listing and execute call.

The package contributes no nodes, no marks, no toolbar items, no icons
and no CSS, so a deployment that does not install it is unchanged. In a
browser without the API, installing is silent: no error, no console
output, and no half-wired editor.

Documentation: new package README, the optional-plugin tables in
README.md and docs/integrating-openleaf.md, the plugin and budget tables
in docs/authoring-plugins.md, a plugin-trust section in SECURITY.md
covering the new caller and the untrusted-content annotation, the
published-package counts in AGENTS.md, docs/releasing.md and
docs/agents/domain.md, and a CHANGELOG entry. demo/index.html is
deliberately untouched: #241 lists the demo page as out of scope.

Refs #241
Closes #242

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…ntent

An agent that has found an editor can now ask what that editor is able to
do, and read what is currently in it.

`openleaf_get_capabilities` answers two questions separately, because in
this project they have different answers. What a document can STORE is the
schema, and the base schema is deliberately wide -- table and structural
nodes are in it whether or not the editing chrome for them was ever
installed, so that a stored document round-trips in every deployment. What
a deployment can DO is the toolbar item registry, narrowed again by the
`toolbar` and `toolbar2` layout the integrator gave that one editor, since
`registerToolbarItem` is page-global and restricting a single editor is a
layout decision rather than an uninstall. So an editor can hold a table in
a deployment that has no command to build one, and can hold a heading on a
bar with no way to apply one. Reporting only the schema would promise an
agent edits that cannot happen; reporting only the commands would tell it a
stored table is unreadable.

`openleaf_get_document` returns the editor's current content as HTML --
`host.value`, so source view and unsaved edits are included -- and is the
first tool in the package annotated `untrustedContentHint`, because a
document is where text aimed at the agent reading it hides.

Both take the editor id the listing hands out. `findEditor` resolves it,
and a miss is never resolved to "the first editor": an agent holding a
stale identifier gets a failure telling it to list again, rather than
somebody else's document. Arguments that do not match the published schema
fail the same way instead of throwing out to the browser, which would reach
the agent as a rejected call with no shape to it.

`@openleaf-editor/ui` becomes a peer dependency for `allToolbarItems()`. It
is already on the shared runtime, so the script-tag bundle grows 0.9 KB
gzipped rather than carrying a second copy.

The harness page's `post-body` editor gains `blockType` in its toolbar: the
capabilities test needs an editor that CAN apply a heading beside the
restricted one that cannot, or a tool reporting no commands at all would
pass it.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…dits

An agent can now search a named editor for a literal string and get back a
handle for each match, then use that handle later in the same task and land
on the same text -- even though the document has moved under it in between.

Addressing has to be its own mechanism here. A selection does not survive
the round trip out to an agent and back: the author clicks somewhere, the
editor re-renders, the agent takes a second to think. So each editor keeps
a table of handles in its own plugin state and carries every one of them
through each transaction's position mapping, which is what makes an edit in
one part of the document leave a handle in another part alone.

The mapping detail is the whole ticket. `tr.mapping.map(pos)` always answers
with a position, so deleting the text a handle names slides it quietly onto
the neighbouring text and a later write lands there -- a stale handle
becoming a write into something nobody chose is the dangerous failure this
mechanism exists to prevent. `mapResult(pos, assoc)` answers with a position
AND whether the token on that side was deleted, so a handle whose content is
gone can refuse instead. It refuses permanently, and it refuses even if the
same characters are typed back: matching characters are a coincidence, not
the thing the agent read. The ends are biased outward, so text typed against
either edge lands outside the handle rather than being adopted by it.

The table is plugin state rather than a plugin-view closure, for the reason
the register already is: `state.reconfigure` destroys and recreates every
plugin view when any other plugin registers, and every outstanding handle
would go with it. Handles are opaque random tokens -- anything an agent can
read out of a handle is something it will eventually compute with -- and an
editor removed from the page stops resolving them without the table having
to be told. Each editor keeps its most recent 256, because nothing releases
a handle and the table is walked on every transaction.

The search itself matches a literal string one block at a time, so a query
spanning a mark boundary is one match and a query spanning a paragraph break
is none; it caps at 50 matches and says `truncated` when there were more,
because an agent that believes it has seen every occurrence will replace
them all. Text that does not occur is an empty result, not an error. The
tool is annotated read-only and as returning untrusted content: it hands
back the text around each match, and a document is exactly where text aimed
at the agent reading it hides.

`resolveHandle` is the seam #245, #246 and #248 consume: one function, and
its failure is already shaped like `fail()`'s arguments so a caller cannot
paper over a stale handle with a message of its own.

Handle mapping is unit-tested against a real ProseMirror view in jsdom,
because it is document-model arithmetic and because nothing in the tool set
consumes a handle yet -- the browser suite has no way to resolve one until
the write path lands. Everything a browser can answer about the search is in
`webmcp.spec.ts`, in all three engines, including finding text the author has
just typed.

`prosemirror-model` joins the peer and dev dependencies at the range every
other package in the repo already uses. It is a type-only import, so nothing
reaches the bundle: the WebMCP bundle is 2.8 KB gzipped against its 8 KB
budget.

Documentation: the tool table, result codes and a new Handles section in the
package README, the untrusted-content paragraph in SECURITY.md, the package
row in README.md, the measured budget figure in docs/authoring-plugins.md,
and a CHANGELOG entry.

Refs #241
Closes #244

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`openleaf_list_editors` tells an agent to pass an "id" back to any other
openleaf_* tool. #244's search tool called the same thing "editor", which
made that instruction wrong on a quarter of the surface and duplicated the
two failure branches `withEditor` already owns. `editorArgumentWith` lets a
tool declare its own arguments on top of the shared one, so the next tools
cannot drift the same way.

Refs #241

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`openleaf_get_structure` names each block of one editor in document order --
its node type, a heading's level, and the start of its text -- and nothing
else. Reading a fifty-section article through `openleaf_get_document` to
retitle one of its sections spends an agent's context before it can act, so
this answers with a map of the document instead of the document.

Every entry carries a handle, minted through the same `createHandles` a search
uses and in the same single step-free transaction, so an outline is something
an agent can act on rather than only read: a handle taken from one still names
its block after an edit elsewhere, and refuses once the block is deleted. The
range is the block's whole node range, boundary tokens included, so the handle
names the block itself rather than what is inside it.

Nested blocks are not listed separately -- a list or a table is one entry, and
searching is how an agent addresses something inside one -- because a recursive
walk would be the document again with different punctuation. An empty paragraph
is not structure, which is also what makes an empty document answer with an
empty outline rather than with the paragraph the schema requires it to have. A
rule or a preserved region carries no text and is still listed: an agent
inserting after it has to know it is there.

Capped at 200 blocks with `truncated`, and the cap is the handle table's rather
than a matter of taste: an editor keeps its most recent 256 handles, so a
longer outline would go stale at the top while it was still being read.

Annotated read-only and `untrustedContentHint` -- an outline is shorter than
the document but it is built from the document's own headings.

Docs: the package README gains an "Outlines" section and a tool-table row,
SECURITY.md names the third tool that returns document content, CHANGELOG.md
gains an entry, and the one-line descriptions in README.md,
docs/integrating-openleaf.md and docs/authoring-plugins.md s.1 now mention the
outline. openleaf-webmcp.min.js measures 4.1 KB gzipped against its unchanged
budget of 8, and s.4.5's table carries the new number.

Refs #245

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…ange

`openleaf_apply_command` runs one of the commands the deployment registered
against the text a handle names, rather than writing markup. A command already
knows what it is allowed to do -- it declines on a figure, it stops at an
isolating boundary, it knows which marks its schema permits -- so an agent
inherits every guard a keyboard shortcut has, including ones a plugin added.
There is no list of command names anywhere in the package.

Only what that editor offers can be applied. `offeredCommands` is lifted out of
the capabilities handler so both tools read the same intersection of the
toolbar-item registry and this editor's `toolbar` layout; a command reported as
available and then refused as unknown would be a contradiction an agent has no
way to resolve. An id nothing registered, or one this editor's bar does not
carry, is `unknown-command`. A control that only works through the editor's own
interface -- `blockType`, `link`, `image`, `source`, which are `render`- or
`run`-driven with no plain command underneath -- is `unsupported-command`, and
saying so is the point: an agent told the heading applied moves on believing it
exists.

A command that declines at that position reports `refused` and changes nothing.
So does an editor that is readonly or has its HTML source view open, matching
what the editor's own toolbar does in both cases -- a change made behind the
source view is applied to the hidden document and discarded when it closes. A
range holding preserved markup is `preserved-region`.

One call is one transaction. The range is staged as a selection on a local
state that is never dispatched, and the command's transaction is captured
rather than forwarded, so "exactly one" is a property of the code and not a
hope about every command an integrator installed. It carries the agent marker,
which #249 will group undo on, and it restores the author's selection: the
staged range is there for the command to read, and leaving it behind would jump
a caret that may be in another paragraph. After dispatch the state identity is
checked, because a `filterTransaction` drops a transaction silently and
reporting that as a write is the failure this tool is shaped to avoid.

A handle may name an inline range or a whole block node, and nothing in a
handle says which. `stage` discriminates on `nodeAfter` and the node's size:
a block range is brought inside the block's boundary tokens, an atom becomes a
`NodeSelection`, and an inline range is unchanged. Without that, both ends of a
block range sit on a boundary token and `TextSelection.between` searches
outward -- a handle naming a horizontal rule came back as a selection over the
paragraph before it.

`marker.ts` and `write-guard.ts` are deliberately small and plainly named: the
HTML write path needs both, and the two copies should collapse into one.

Docs: the package README gains a tool row, the new error tokens and an
"Applying a command" section; SECURITY.md gains a paragraph on the first tool
that writes; the CHANGELOG, the integration guide, the root README and
authoring-plugins §1 all name the new capability. The §4.5 budget row is the
measured 5.1 KB gzipped against an unchanged 8 KB budget.

Refs #248

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An agent can rewrite the passage a handle names, and every guard the editor
makes to a person still holds.

The HTML is sanitized before it is parsed, by the same `normalizePastedHtml`
a human paste goes through, and that ordering is the whole of the guarantee.
The preservation layer is a catch-all -- markup the schema does not recognise
is wrapped and kept rather than rejected -- so parsing agent HTML first would
turn hostile or malformed input into an opaque atom the document then carries
faithfully forever, preserved precisely because nothing could parse it.
Content the policy leaves nothing of is refused rather than written.

A range covering preserved markup is refused outright: the promise to hand
that markup back byte-identical only holds if nothing edits inside it, and the
route an agent has to such a range is real, not theoretical -- the search
stands an inline atom in for one object-replacement character, so searching
for that character hands back a handle onto preserved content.

Every check runs before anything touches the editor, so a refused write is not
a partial write; it is not a write. Each call dispatches exactly one
transaction, carrying a `PluginKey` marker #249 will key undo grouping off.

The checks, the sanitize-then-parse step and the single dispatch live in
`src/write.ts` rather than inside the tool, because #247, #248 and #250 all
build on them and a second copy that drifted would drift in the direction of
writing to the wrong place. `writeAt` takes a callback that returns a
transaction; the one `view.dispatch` in the package is inside it.

`agentSlice` fits content to both shapes a handle comes in: a search's inline
range inside one textblock, and an outline's whole-block node range. A lone
paragraph going into inline text contributes its contents, so a model wrapping
its answer in `<p>` does not split the sentence it was editing; anything else,
and anything over a block range, goes in as blocks.

Tests: `packages/plugins-webmcp/test/write.test.ts` for the paste policy, the
preserved refusals, the block-range shape and the transaction count and
marker; `webmcp.spec.ts` gains a writing block asserting through `stored()` in
three engines, including the browser-side proof #244 owed -- a handle taken
before the author edits elsewhere still names its own text afterwards. The
webmcp harness gains the two pieces of preserved markup those need.

`@openleaf-editor/paste` becomes a peer dependency; it is already on the
shared runtime, so the bundle borrows it. `openleaf-webmcp.min.js` measures
4.8 KB gzipped against its unchanged 8 KB budget.

Docs: the package README gains a tool-table row, the two new error tokens and
a "Writing" section; SECURITY.md gains a paragraph on the sanitize ordering
and the preserved refusal; the root README, the integration guide, the plugin
table and the §4.5 budget table in `docs/authoring-plugins.md`, and the
changelog are all updated.

Refs #246

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Installing the agent tool set is a coarse decision: it offers an agent
every tool or none of them. A host that wants an agent to read its
documents and not rewrite them, or to touch the draft editor and not the
published one, had nowhere to say so short of forking the package.

`installAgentTools({ allowTool })` takes a synchronous predicate asked
before every tool call. It receives the tool's name, the editor
identifier the call names, and the tool's own `readOnlyHint` -- so
"allow reads, refuse writes" is `({ readOnly }) => readOnly` rather than
a list of tool names that goes stale the moment one is added. Answering
anything but `true` returns `refused` to the agent and changes nothing;
the question is asked before any argument is validated and before
anything touches an editor, so a refusal is not a partial call. A
predicate that throws is treated as a refusal rather than reaching the
agent as a rejected call with no shape to it, and nothing of the thrown
error travels back with it. With no predicate, every tool behaves
exactly as it did.

The gate wraps each descriptor where the set is composed rather than
living inside the handlers. That is what makes it cover
`openleaf_list_editors`, which takes no editor and so passes through
neither of the package's two argument chokepoints, and it is what makes
a tool added later gated by having been added -- there is no line for
its author to forget.

From a script tag the predicate is `OpenLeaf.registerAgentPermission`,
next to `agentTools` on the `registerSaveHandler` precedent: that bundle
installs on load, so `installAgentTools`'s options argument is spent by
the time an integrator's own script runs.

`openleaf-webmcp.min.js` 6.4 -> 6.6 KB gzipped against the unchanged 8
KB budget.

Refs #250

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An agent could rewrite the passage a handle names; it could not add one
beside it. `openleaf_insert_html` takes the same handle and a `position`
of "before" or "after", leaves the named text alone -- so the handle is
not spent -- and goes through the same `writeAt` chokepoint every other
write does: one transaction marked as the agent's, the paste policy over
the HTML before the parser sees it, preserved markup refused, and nothing
touched at all when any of that says no.

The one thing insertion does that replacement does not is ask the schema
whether the content may sit at that position. Replacement is fitted to
the range it lands in, which is right for a call that means "this text
becomes that". Fitting an insertion means a heading aimed into the middle
of a sentence splits the paragraph in two, and emphasis aimed into a code
block is dropped on the way in -- both reported to the agent as successes
it can build on. So `parent.canReplace` is asked before anything is
built, and an insertion it refuses answers `invalid-position` with the
content expression that position does hold, which is what tells an agent
to reshape the HTML or ask an outline for a handle naming a whole block.

`invalid-position` is a new `ToolErrorCode`, separate from
`rejected-content` because here re-reading the schema is exactly what
helps.

Docs: the package README gains an "Inserting" section and the new token;
SECURITY.md's paste-policy and annotation paragraphs name the new tool;
CHANGELOG, the root README, the integration guide and the plugin table in
docs/authoring-plugins.md follow. The §4.5 size row moves 6.4 -> 6.9 KB
gzipped against the unchanged 8 KB budget.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…e command

`markAgent`'s payload meant two things at its two call sites: `openleaf_replace_at`
marked `{ tool: 'openleaf_replace_at' }` while `openleaf_apply_command` marked
`{ tool: 'bold' }` -- the command id. A field called `tool` that means a tool on
one write path and a command on another is a field nothing can read, and it is
about to have a reader.

Both call sites now mark the tool name. It is the value every write path has,
it is the string the agent actually called, and it is page-global where a
command id is not -- `registerToolbarItem` is last-wins, so `bold` may be an
integrator's command rather than the built-in one. Which command ran is already
reported in `openleaf_apply_command`'s result, which is where an agent reads it.

`markAgent` becomes private behind `dispatchAgent`, the one dispatch in the
package: it marks, dispatches, and answers whether the transaction survived the
editor's `filterTransaction`. `openleaf_apply_command` still cannot hand
`writeAt` a finished transaction, but it no longer has to remember the marker or
repeat the did-it-land check to get one.

Refs #249

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An author who watched an agent restructure a document presses Ctrl+Z once and
has the document back as it was before the agent started. Redo brings the whole
run back the same way.

The editor's default cannot answer this. `history()` groups by elapsed time and
adjacency, a plugin cannot change its options, and agent calls arrive in a
burst -- so the same six-paragraph rewrite collapses into one step or fragments
into six depending on how fast the model answered and how far apart the
paragraphs were, and the author has no way to know how many times to press.
Grouping keys off the marker every agent write already carries, so a slow agent
and a fast one produce the same one step.

Three mechanisms, one per edge of the run, and none of them touches a
transaction the author produced:

- `appendedTransaction` on the second and later writes of a run holds it
  together at any elapsed time -- the trick `core/src/autolink.ts` uses.
- `closeHistory` on the first write stops the run reaching backwards into the
  author's preceding typing, and is what makes a human edit between two agent
  writes break the run.
- `setTime` stops it reaching forwards. History merges the next transaction if
  it is inside `newGroupDelay` and adjacent, and an agent write is both: it
  happened just now and the author's caret is often exactly where it landed, so
  without this the first thing they typed afterwards was undone with the
  agent's work.

"Consecutive" is doc identity rather than a flag or a clock: a `doc` node is
replaced only by a transaction that changed the document, so the caret moves,
focus changes and step-free handle transactions that pass through a live editor
cannot break a run, and a real edit cannot fail to.

`prosemirror-history` joins the peer dependencies at the range core and element
already use, for `closeHistory` alone. It is already on the shared runtime, so
the bundle borrows it: 6.4 -> 6.6 KB gzipped against the unchanged 8 KB budget.

Closes #249

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`agentSlice` handed the agent's own string to `normalizePastedHtml`, which
dispatches on `detectSource`. One of its branches is laxer than the rest:
`looksLikeOpenLeaf` is the bare presence of `data-pm-slice=`, and it selects
`normalizeOpenLeaf`, which keeps inline styles because a copy out of this
editor is in the same trust domain as where it is going.

An agent's argument never is, and the marker is one the agent writes itself --
so an agent could pick its own sanitizer and land `style="position:fixed"` in
a document, which is exactly the markup this package exists to make
unreachable. The choice is now taken away from it: markup that detects as an
internal copy goes through `normalizeGeneric` instead. The other three
branches (word, excel, gdocs) all end in `stripAllStyles` and strip their own
vendor debris besides, so steering into one of them can only make the policy
stricter and is left alone.

Regression coverage at both levels, with the attribute-bearing string the
existing fixtures omit: `write.test.ts` and `webmcp.spec.ts`. Both fail
against the previous dispatch.

Docs: the `Writing` bullet in the package README and the paste-policy
paragraph in SECURITY.md now say the policy is applied as it is to foreign
input, rather than implying `normalizePastedHtml` is one policy.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`run` called `command(staged, capture, view)`. A `Command` is
`(state, dispatch, view)`, and the third argument is a way out of the second:
a command handed the live view can call `view.dispatch` itself and write
unmarked, ungrouped, past the did-it-land check, and outside the "exactly one
transaction" guarantee the function's own comment claims. Commands here are
third-party code -- `registerToolbarItem` is last-wins, so even a built-in id
may be an integrator's -- so this was not hypothetical.

It now gets the real view with one property shadowed: `Object.create(view)`
with its own `dispatch`, so every other read falls through to the real object
(`state`, `dom`, `someProp`, `coordsAtPos`) and only the dispatch is ours.
Passing nothing instead would have broken every command that legitimately
measures or focuses through the view, for the sake of the one that dispatches.

Test: a registered command that dispatches through the view produces exactly
one transaction, marked as the agent's, with the content it asked for.
Against the previous call it declined and wrote anyway.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…uard

Two duplications the concurrent tickets left behind.

`apply-command.ts` re-declared the `handle` argument inline instead of
spreading `handleArgument`, which `write.ts` exports precisely "so all of them
describe it alike". Its copy had also gone stale: it said "A handle from
openleaf_find_text", omitting `openleaf_get_structure` -- even though the same
file's `stage()` exists to handle an outline handle's whole-node range. An
agent reading the schema was told outline handles are not accepted. It now
spreads the shared one, and its description says both sources too.

Six near-identical string-argument guards in two spellings (`typeof x !==
'string' || x === ''` and `typeof x === 'string' ? x : ''`) collapse into
`stringArg(args, name)` in `editor-arg.ts`: one reading of "whatever the agent
actually sent", used by `withEditor`, `targetFor`, and the `text`, `html`,
`html` and `command` guards. The refusal message stays each tool's own -- it is
the only place an agent is told what to send instead.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Seven of the eight tools name the editor they acted on in their success
payload; the search was the one that did not. An agent driving several editors
had to pair a result with the call it came from by position, which is the kind
of bookkeeping the id exists to remove -- and the odd one out in a set whose
whole value is that the tools answer alike.

`{"ok":true,"id":string,"matches":[…],"truncated":boolean}` now, with the
tool's own description, the package README, the jsdom test and the browser
test that pinned the old shape updated with it.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`registerAgentPermission` was last-writer-wins and accepted `null`. It is hung
on the page's own `OpenLeaf` global -- it has to be, because the script-tag
bundle installs on load and `installAgentTools({ allowTool })` is therefore
unreachable from a script tag -- so any script that ran after the integrator's
could hand the tools a policy of its own or clear it back to "allow
everything". That is a hole in the one thing #250 exists to give an
integrator, and #242 already says a second `installAgentTools` is ignored,
options and all.

The first predicate registered now wins, whichever door it came through, and a
later call is ignored: another predicate, `null`, or an `installAgentTools`
that supplies its own. The `typeof` guard is for the untyped script-tag caller
that would still reach for `null` to clear one. This is not a defence against a
script that already has the page -- it can call `agentTools` directly -- it is
what stops the policy being replaced, by a third party or by accident.

A policy that changes with the host's state belongs inside the predicate, which
is asked on every call; that is now said in the package README, the integration
guide, SECURITY.md and the changelog entry.

`permission.test.ts` re-imports the package per test, because the state under
test is exactly the state a test used to reset -- and the register and handle
table have to come from the same fresh instance the tools read. Three new
cases: a second registration, a clear, and a later install with its own
predicate. `webmcp.spec.ts`'s "goes back to allowing everything when the policy
is cleared" becomes its opposite, driven through the same script-tag hatch.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…stallers

Three stale claims, all of them about a bundle that has since been built.

`scripts/bundle-budgets.mjs` argued 8 KB "against a measured 1.3", as room for
a tool set arriving one tool per issue. That has played out: all nine tools of
#241 are in and it measures 7.2, so the comment now says what is true --
roughly 0.8 KB left, one more tool at recent prices, and past that a decision
about the surface rather than a number to raise.

`demo/build.mjs` called it "the smallest bundle here, and it should stay that
way". It is not: import (3.3), colour (5.3) and highlight (6.7) are all
smaller. It has the smallest budget, which is a different claim, and what
should stay that way is the structural reason -- no icons, no stylesheet, no
dialogs.

`packages/element/README.md`'s optional-plugin list never gained
`plugins-webmcp`, though the root README, the integration guide and
`docs/authoring-plugins.md` all did.

No behaviour change; `openleaf-webmcp.min.js` still measures 7.2 KB gzipped
against the unchanged 8 KB budget, which is the figure §4.5 already carries.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
#241 asks that the guarantees be asserted through the stored form value, and
`openleaf_replace_at`'s "exactly one transaction per call" had no such proof:
`write.test.ts` counts transactions in jsdom, and the browser suite showed only
that a *run* of three writes is one press -- which the undo grouping would give
for a call that dispatched twice as well.

One write, one `Ctrl+Z`, back to the value the form would have posted before
it. The sibling claim for `openleaf_apply_command` was already covered by
"lands as one undoable step"; the marker itself is proved in the browser by the
two grouping tests, which an unmarked write would fail.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
… too

The seventh copy of the same reading, and the last one in the other spelling:
`typeof id === 'string' && id !== '' ? id : null`. It answers a different
question from the six guards -- which editor the agent named, if any, rather
than a refusal -- so the `null` stays, but the string comes out of `stringArg`
like everywhere else.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
The outline test clicked the first paragraph and pressed End before
typing. `toBeFocused()` resolves before the click has moved the caret,
so the keypress raced it: the text landed at position 0 and Enter split
above it, giving "a second paragraphalpha beta" and an empty block. It
failed in isolation, not only under load.

The caret is now set through the view by arithmetic, the same idiom
keyboard.spec.ts uses. 5 repeats on chromium and the full suite on all
three engines are green.

Refs #241

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
@PeytonNowlin
PeytonNowlin marked this pull request as ready for review August 28, 2026 02:20

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc30bbb349

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/plugins-webmcp/src/permission.ts
Comment thread packages/plugins-webmcp/src/apply-command.ts
Comment thread packages/plugins-webmcp/src/editor-arg.ts
Comment thread AGENTS.md
`undo` and `redo` are registered, are first on the default bar, and have a
plain `command` -- so every guard in `openleaf_apply_command` passed them
through. They are also the one pair that ignores the selection: they revert
the last history event wherever in the document it happened. An agent could
hand one a valid handle naming its own passage, revert an author's unrelated
work somewhere else, and be told the handle-scoped call succeeded.

A toolbar item now declares what it acts on. `scope: 'document'` says the
command does not read the selection; `selection` is the default, so every
other item is unchanged and the toolbar does not read the field at all -- for
a person clicking a button the distinction is invisible and correct. The
webmcp tool refuses a `document` command with `unsupported-command`, beside
the refusal for a control that has no command underneath it, so there is
still no list of command names in that file.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
The contract is that `true` allows a call and anything else refuses it, and
`if (!allowed)` did not say that. A predicate the integrator wrote as `async`
answers with a Promise; one whose body ends in the session it looked the
decision up in answers with an object. Both are truthy, and neither is a
policy that said yes -- but both were read as authorization, for reads and
for writes alike, on a page whose host thought it had installed a veto.

The answer is now typed `unknown` and compared with `===`. The type said
boolean and nothing made that so: this is host code reached through a
function on the page's own global, often from a script tag with no types in
front of it at all. A predicate whose answer needs an `await` cannot be
answered here, so the documentation says to cache the decision and have the
predicate read the cache.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Nothing validates a call against the schema a tool published -- the browser
parses the agent's JSON and hands over whatever it parsed -- so a top-level
`null`, `undefined`, a number or a string reaches `execute` as the arguments.
Every handler reads a property off that as its first act, `openleaf_list_
editors` included through the permission gate's own `id` lookup, so the call
threw before any of them could answer `invalid-argument`. A throw out of a
handler reaches the agent as a rejected call with no shape to it and nothing
to retry against, which is the one outcome every result in this package is
written to avoid.

Normalised once in `gated`, which is the seam every tool is composed through
for exactly this kind of invariant: a non-object becomes an empty one, and
each handler goes on to say which argument was missing in its own words. The
test asserts it over the whole set rather than a list of names, so a tool
added later is covered by having been added.

The `demo/build.mjs` figure moves 7.2 -> 7.4 KB, which is the refusal text
these three fixes added; the budget is 8 KB and `check-docs` pins the number.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…ries

The three fixes are corrections to features that have not shipped, so they
belong in the entries that describe them rather than under Fixed: what
`openleaf_apply_command` refuses, and what the integrator's predicate has to
answer. `ToolbarItemSpec.scope` is new public API on `@openleaf-editor/ui`
and gets an entry of its own.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
@PeytonNowlin

Copy link
Copy Markdown
Owner Author

Cursor have you reviewed this?

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Human TLDR:

  • Two should-fix correctness bugs: editors can share the same agent id (writes then hit the first one), and while HTML source view is open get_document reads the textarea but search/outline still read the hidden document.
  • The earlier Codex findings (permission === true, undo/redo refused, non-object args) look fixed on this head.

Review

Looked at the new WebMCP write, handle, permission, and registry paths only. No security injection hole beyond the paste policy this package already documents.

Open in Web View Automation 

Sent by Cursor Automation: PR Review

Comment thread packages/plugins-webmcp/src/registry.ts Outdated
Comment thread packages/plugins-webmcp/src/find-text.ts
…se reads in source view

Both from a review round on #252.

The identifier check only covered attribute-against-attribute. The generated
name shares one namespace with the integrator's own `id`, and `editor-2` is a
name an integrator really writes -- the README documents that exact spelling --
so a page whose first editor was `id="editor-2"` handed the same name to the
second editor, which has no id and is second on the page. Both answered to it,
`openleaf_list_editors` returned it twice, and `findEditor` resolved every later
call to whichever was registered first: replacing text in one rewrote the other,
and the agent was told `{"ok":true}`. The ordinal now walks past a name that is
already live, and the count is shared so a skipped name is never handed out.

`openleaf_get_document` reads `host.value`, which is the source textarea once
source view is open; `openleaf_find_text` and `openleaf_get_structure` still
walked `view.state.doc`, the document that is not reparsed until the view
closes. An agent could read the markup, search it for a string it had just been
given, and be told the string is not there -- or be handed a match in text the
author had already deleted. Both now refuse with the same `refused` token the
write path uses. Not by parsing `host.value` into a throwaway document: the
handles that would mint point into a document that is not the live one, so no
later call could write through them.

The source-view question now has one home, `source-mode.ts`, asked by the two
reads and by `refuseWrite`, so the three cannot drift on what they say about it.

Also bumps the measured `openleaf-webmcp.min.js` claim in authoring-plugins.md
from 7.4 KB to 7.6 KB; the new module put it past the docs gate's tolerance.
Budget headroom is unchanged at 7.6/8 KB.

Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant