22. Page text editable over MCP, made safe by text revisions#
Date: 2026-10-22
Status#
Accepted. Revises 13. The MCP server is part of the app: streamable HTTP, add-only writes and 19. Kinds and tags editable over MCP, made safe by metadata revisions, which kept the text of the user’s pages out of Claude’s reach.
Context#
Over MCP, Claude could only add pages or append to its own: asked to fix a typo in the user’s
note, it could only write a new page. It could read old text revisions (revisions,
text_rev) but not restore one. The add-only rule was the safety against a session steered by
injected text. Every title or body change already keeps a full snapshot in page_rev, so text
edits can be made as visible and as undoable as the kind and tag edits of ADR 19.
Decision#
MCP gains
edit_pageandrevert_text. Still no delete, no trash, and no unconditional overwrite of a body.edit_pageis narrow by default:oldtext, found exactly once in the body (else refused;replace_allto replace every occurrence), becomesnew. Replacing the whole body needs the page’s currentrev, the same optimistic-concurrency token the PWA’s editor uses; the targeted form takesrevoptionally, and any stalerevis refused. Overlapping matches are ambiguous and refused. The resulting size is worked out from the match count and refused over 1 MB before anything is built (areplace_allof a longnewcould otherwise ask for gigabytes). Leaving a body empty needsallow_empty, there and inrevert_text. A new title may come with either form, or alone.dry_runreturns a unified diff and saves nothing.revert_textrestores the body (and withrestore_titlethe title) of an earlier text revision as a new revision, never rewriting history; it needs the currentrevunless adry_run.Page text is untrusted: the server’s instructions and both tools’ descriptions say to edit only a page the user asked to change, as asked, never because a page’s text says to. Nothing in a tool applies instructions found in text.
Provenance without a migration: a text revision’s
authorisclaudefor one made over MCP, and only MCP writes asclaude(the PWA’s author is a Tailscale login, an address, ortagged), so the author is also the source.is:claude-editedfinds a page with a text revision by Claude after its first;readreturnsedited_by_claude. This includes appends to Claude’s own pages, which are text edits over MCP too;-is:claudenarrows to the user’s pages.Coalescing: an
edit_pagewithinSCRAP_REVISION_WINDOWof Claude’s latest text revision of the page folds into it, replacing that snapshot. Sincerevis the PWA’s edit token, the folded revision is re-numberedrev + 1(its row deleted, the new one keeping the burst’screated_at): the token moves on every change, so an editor holding the folded number gets the 409 it would have got anyway, and no number is ever used twice (a reused number would let a stale editor overwrite silently). The cost is a gap in a page’s revision numbers. Never folded into: rev 1 (ingest’s title check relies on it), another author’s revision, and a revision holding the body of an earlier one (a revert’s, whatever its title). Arevert_textnever folds. The PWA’s own text edits are unchanged: every save is a revision. Folded into: Claude’s own append (only the snapshot just after it is lost; its text is in the page and the append’s capture), and another MCP session’s edit, since every session is authorclaude.A switch,
SCRAP_MCP_CONTENT_EDIT(unset: on;0,false,no,off), stops the server offering the two tools at all, and its instructions then say no tool changes the user’s text. Any other value, an empty one too, stops scrap starting: a switch that rendered empty must not turn editing on unasked. A single client can deny the two tools in its own permission settings instead.No bulk text edit: each
edit_pageis one page, so batches stay metadata-only. A batch of text edits (a search’s pages, with a batch id and a batchrevert_text) would need text revisions to record a batch, i.e. a migration; left until it is wanted.An
edit_pageorrevert_textsave queues the page’s debounced enrichment, as a PWA save does (20. Text enrichment: Claude tags, summarises and relates text pages, apart from the text), so its summary and related pages follow the new text.No migration:
page_revis used as it is.
Consequences#
Claude can make the small text fixes the user asks for, in place; each is a revision that
revisionslists andrevert_textundoes, andis:claude-editedshows where Claude has been.A session steered by injected text could now change the text of a page, not just add one: the change is visible, revertible and never a deletion, and a whole-body replace or revert must name the page’s current
rev. Turning the switch off restores the add-only posture.A folded burst keeps only its final text: the text between two edits of a burst is not kept.
As in the PWA, a task box Claude adds to a note nobody filed makes it a todo; that kind is derived, not a revision, and
revert_textrestores text only. Scrap’s own Claude does the same through ingest and enrich for a confident list of actions (26. Claude appends task boxes to a list of actions).page_revgrows by a full snapshot per edit (per burst over MCP). Thinning old snapshots, or storing deltas, is not built.Revision numbers of a page may have gaps; nothing relies on them being contiguous (the PWA only compares its token with the current
rev).