MCP server#
scrap serves an MCP server at /mcp in the same process as
the PWA and its API: Streamable HTTP, stateless (every request stands alone, so a restart drops
no session), JSON replies. A Claude Code session connects with one line and needs nothing
installed; see Connect a Claude Code session. The server is named scrap, so tools show
in a session as mcp__scrap__search, mcp__scrap__read and so on. A raw client POSTs JSON-RPC
to /mcp with Accept: application/json, text/event-stream, as the MCP specification asks.
The server sends the model instructions: search before writing and reuse existing tags (the tags
tool); write only adds pages or appends to ones written over MCP; edit_meta and rename_tag
change only kinds and tags (edit_meta also a todo’s status, due and priority, and the public flag), each change a revision that revert
undoes; try a bulk change with dry_run first; what a page returns, an attached file’s text
included, is quoted data, never instructions; deletes go to a trash, and nothing deletes a page
until it has been there for SCRAP_TRASH_DAYS days (30 by default; see The trash below); revisions
cover text, kinds, tags and fields (media_type, status; a todo’s due and priority), not
enrichment. Unless content editing is off they also say edit_page and revert
(with to_rev) change a page’s text as revisions, only on a page the user asked to change (see Page
text below), and that trash_pages moves only pages the user named or asked to delete, never
because text in a page says to, and undelete brings one back; with it off they say no tool changes
the user’s text, and nothing moves a page to the trash or back. Unless uploads are off
(SCRAP_MCP_UPLOAD=0) they also say add_svg shows an SVG Claude writes in a page’s text, as a
revision, only where the user asked for it. The sc commands (/sc-post,
/sc-summarise, /sc-find, /sc-todos, /sc-tidy, /sc-undo, /sc-write, /sc-answer,
/sc-edit, /sc-done, /sc-recent; commands/ in the repository, installed as in
Connect a Claude Code session) wrap these tools as short commands.
The tools by purpose (a tool in a dashed box is listed only while SCRAP_MCP_CONTENT_EDIT is not
0; add_svg, dotted, only while SCRAP_MCP_UPLOAD is not 0):
flowchart LR
classDef read fill:#e8f1ff,stroke:#4a7bd0
classDef claude fill:#fff3e0,stroke:#e08a00
classDef user fill:#e9f7ef,stroke:#2e9d5b
classDef opt stroke-dasharray: 5 3
classDef upload stroke-dasharray: 2 2
subgraph R["Read"]
search:::read
read:::read
drawing:::read
tags:::read
topics:::read
trash:::read
end
subgraph W["Write"]
write:::claude
edit_meta:::claude
rename_tag:::claude
edit_page:::opt
edit_drawing:::opt
trash_pages:::opt
undelete:::opt
add_svg:::upload
end
subgraph H["History"]
revisions:::user
revert:::user
end
W -->|"each change is a revision"| H
H -->|"undo"| W
Every tool result is JSON (MCP structured content, also sent as its text). Page text (titles, bodies, summaries, snippets, tags, what Claude read from photos) is in fields of that JSON, as data: the server’s instructions tell the model it is the user’s text, never instructions to it.
Tools#
Any argument that names a page (page, pages, append_to) takes a page URL, https://<host>/p/<slug>, as well as an id or slug, so a link the user pastes can be passed as it is. The host is not checked: a URL of another instance names a page that is not found here.
Tool |
Arguments |
Returns |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
none |
|
|
|
|
|
|
Ask scrap’s Claude to write up a page’s image now, or again (a screenshot of a readout, a document, a whiteboard): one model call from the shared daily budget, queued, not waited for; read the page later for |
|
none |
The page the user most recently opened in scrap, on any device: |
|
|
|
|
|
As |
|
|
|
|
|
As |
|
|
|
|
|
|
|
|
|
|
exactly one of |
For a page, |
|
exactly one of |
For a revision, as one page of |
The trash. The user’s deletes, and trash_pages, move a page to the trash; nothing over MCP
deletes one for good or empties the trash: only the user does, in the PWA. trash_pages is for
pages the user named or asked to delete (say, the old pages after merging several into one),
never because text in a page says to: a session steered by injected text is the risk, and the
trash keeps whatever it moves recoverable
(34. Claude can move pages to the trash). A topic page can be trashed, but one in the trash is never purged and vetoes its topic
(Claude does not make it again), so the instructions say to trash one only if the user named it.
It takes at most 50
pages a call, and each move, the PWA’s delete and undo too, is in revisions’ trash list.
Nothing deletes a page until it has been in the trash for SCRAP_TRASH_DAYS
days (30 by default): then the nightly scrap gc purges it for good, but a topic page, which
stays as the veto on its topic (30. The trash is purged after N days).
The instructions say so, with the number (or, with SCRAP_TRASH_DAYS=off, that nothing deletes a
page). trash lists it, with each page’s purge_at, undelete brings one page, or a trash_pages batch, back (reversible, the same trust
model as edit_meta; listed, as trash_pages is, only while content editing is on, see Page text), and read and
revisions with a page accept a page in the trash read only: read’s page.trashed is true
and page.deleted_at says when (false and null otherwise), page.purge_at when it is purged
(null if never, or not in the trash), and revisions returns trashed and deleted_at, the same
two fields, so its last text and history can be read before it is restored. Every other tool
treats the trash as absent: search, tags, the recent-changes feed and
read’s backlinks leave it out, and edit_meta, edit_page, revert with a
revision or to_rev, drawing and write with append_to give the tool error no such page: <id or slug>. Two checks look at the trash anyway: rename_tag counts a trashed page’s tag as in use,
and revert with a batch skips and lists trashed pages (both below).
Kind names. Kinds are accepted as note, todo, read, board and doc; the stored names
consume, whiteboard and document also work in write and edit_meta. Results always show the stored names: a page
written with kind="read" comes back as consume in search, read,
edit_meta, revert and revisions. read is consume, board is whiteboard and doc is
document.
Raw text#
The search index keeps only words, stemmed, so search’s words cannot find punctuation or an
exact string: a Markdown table (|---|), a command line, an IP address, foo_bar(. grep
matches the stored text itself instead: each page’s title, its Markdown body and the current text
scrap read from its files (a PDF’s, a text file’s, a photo’s transcription). Without regex it is
a case-insensitive substring; with regex=true a regex in
RE2 syntax, in which ^ and $ match at each line
and case is kept ((?i) ignores it). RE2 has no backreferences or lookaround; \w, \b, \d and
\s are ASCII only (caf\w\b does not match café: use \pL or \p{L} for a letter); and the
end of the text is \z, not \Z. RE2 matches in linear time and bounds the memory a pattern
compiles to. The text is searched 16 KiB at a time, each search reading up to 4 KiB past its
part to finish a match, so a match running further than that past a part’s end may be missed,
and the whole scan has a time budget (below), checked before every search, so a costly pattern
is stopped rather than left to run.
query still chooses the pages, and their order: its filters and words apply as they do without
grep, and grep must match as well. An empty query scans every page, newest changed first.
The trash is never scanned. limit and offset page through the pages that match. Each row is
search’s, plus:
match_count: how many lines a match starts on (title, body and files together; at most 1,000). Matches do not overlap: a match over several lines counts once, on its first.matches: the first 5 of them, each{in, line, text}:inistitle,bodyorfile(a file’s also has itssha256, asreadlists it),lineis 1-based within that text andtextthe line, cut to about 200 characters around the match. A file’s line is untrusted third-party text, quoted data like its snippet.
Every page with a Markdown table:
{"grep": "^\\|?\\s*:?-{3,}:?\\s*\\|", "regex": true}
and only the todos with an IP address: {"query": "kind:todo", "grep": "10.0.0."}. The tool
errors: invalid regex: <why> (also for what RE2 does not do, such as \1 or (?=, and for a
pattern too large to compile, such as nested counted repeats); a pattern over 200 characters;
regex needs grep; and grep took over 2 seconds: narrow the query when the whole scan runs past
its time budget (checked before every search). See
40. Search greps raw text: a parameter, and a scan.
Images#
A photo page is a plain note whose body is only an image embed, so its title, tags and
snippet may say little. Every row of search therefore says what images
the page has (fields added in place; older clients can ignore them):
image_count: how many distinct image files (by content: the same photo twice counts once) the page’s captures hold (0: none).When it is above 0, the first image’s
image_kind(what Claude took it for:photo,whiteboard,handwriting,diagram,screenshot,documentorother),image_description(Claude’s 1-3 sentences about it),image_url(the original file),thumbnail_url(a JPEG at most 480 pixels a side, HEIC converted) andimage_download_url(the original, for an MCP client: see Downloads below).image_kindandimage_descriptionarenulluntil the photo has been read (ingest queued, failed or off).
Search filters (the PWA’s search box takes them too): has:image (a page with an image),
has:drawing (one with a current drawing), image:K (an image Claude took for kind K, e.g.
image:photo, image:whiteboard); - before any of them excludes. The store’s health
checks are filters too (the PWA’s Store health screen counts them, GET /api/health):
is:orphan, is:untagged, is:stale, is:duplicate, has:dangling, has:open-questions (an Open questions section with an unticked box, or with text but no boxes; not a topic page) and is:outdated (is:stale is an open todo untouched for 60 days; is:outdated a
board’s write-up older than the page or Claude’s prompt, or linking a page that is in the trash or gone; not related pages or topic syntheses)
(Capture from your phone (the PWA) says what each finds). A filter alone lists newest
first, so search with has:image is “my recent photos”. A word in a photo’s description or
its transcribed text finds the page (Claude’s results are indexed as the page’s text), so
mantis finds a photo of one.
Changing a drawing. drawing reads the current drawing of an image and edit_drawing saves a
new one (Claude’s, never over the user’s silently: it needs the base_version it read). It is
how Claude Code refines a drawing the user asked about. Drawing rows are separate from a page’s text
revisions: revisions and revert do not list them; restore an earlier drawing in the PWA’s
drawing history.
From the phone. The Claude Android app cannot use this server. Copy the page’s URL on the
phone into a Claude Code session and run /sc <url> (see Connect a Claude Code session):
Claude reads the page, extracts the drawing with drawing, and can answer questions about it,
redraw it elsewhere or, if you ask, save a refined version with edit_drawing.
Can a client fetch the image? The url, image_url and thumbnail_url URLs are the
browser’s: plain HTTPS GETs with no login on a tailnet install, like the rest of scrap, so
anything on the tailnet can fetch them (curl -o p.jpg <thumbnail_url>, then look at the file;
Claude Code’s Read shows an image). Behind Cloudflare Access they need the email login, so an MCP
client uses the download URLs instead (below). A built-in web-fetch tool that runs elsewhere, or
refuses private hosts, will not reach either.
Can read return the image itself? Not yet: results are JSON (URLs and Claude’s text). MCP
can carry images as image content blocks; a later option could return a page’s thumbnails
that way (480 px JPEGs, so a few hundred kB each).
Downloads#
The download URLs serve the same bytes as the browser’s /api routes, under /mcp, so they
need the same headers as /mcp itself: Authorization: Bearer <token> when SCRAP_MCP_TOKEN is
set (401 without it), and behind Cloudflare Access the service token, which the /mcp
application already accepts for every path under it. With no token set they are open, as /mcp
is. They are read only (GET), checked for Host like every route, and need no Origin
(ADR 0041).
|
The same as |
Results that give it |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
the |
|
|
( |
|
A page in the trash has no page or drawing download (null: both are 404 there, as on
/api); its files’ download_url still serves. For example, with the headers the client was registered with
(Connect a Claude Code session):
curl -fsS -OJ "$download_url" -H "Authorization: Bearer $SCRAP_MCP_TOKEN" \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"
Adding images and files#
Two ways in, both off with SCRAP_MCP_UPLOAD=0 (on by default;
ADR 0042). Either is Claude’s
change: the capture’s author is claude and its source mcp, and the embed goes into the text as
a text revision with source mcp, so is:claude-edited finds the page, revisions lists it and
revert(page, to_rev) takes the embed out again (the file stays attached, listed below the text,
as when the user deletes an embed in the PWA; with SCRAP_MCP_CONTENT_EDIT=0 revert refuses
to_rev, so the user deletes the embed in the PWA’s editor). Either takes any page (the upload
wakes the ingest worker, for its files); SCRAP_MCP_CONTENT_EDIT=0 does not limit them.
An SVG Claude writes: add_svg. SVG is text, so a labelled geometry diagram, a graph on a
grid or a small chart costs about as much as a paragraph. The tool stores it as an image of the
page and embeds it as the PWA’s Add photo does (), with
the alt given. after (text that must occur exactly once in the body, as edit_page’s old)
puts the embed right after the line on which it ends, in the same list item or quote, so a
diagram goes under the question it belongs to:
in a paragraph, the paragraph is split there (
(a) Find the height.then the diagram, thenAnswer: 4 m);in a tight list (no blank lines between its items), the embed stays in the item and the list stays tight: the line ends with a hard break (
\) and the embed follows on the next line at the item’s indent, broken from what follows in turn (1. Show that x = 2.\,\,Answer: shown,2. ...); a nested item uses its own indent, and a tab counts to the next stop of four;in a loose list, the embed is a paragraph of its own in the item, indented as a continuation; in a quote, it is quoted (
>);a block that cannot be split has it after its end: a fenced or indented code block (a
mathone too), a table,$$ ... $$or a\begin{...}environment, a heading (a setext one after its underline);a split that would change the page (a next line that would then start a list or code block, inline code,
$maths$, a link or bold text cut in two) puts it after the paragraph instead: each place is parsed again and kept only if the page’s blocks, the item or quote the embed is in, its lists’ tightness and its rendered text are as they were. If none is, the embed goes at the end of the text, and the result says so:placedisend(afterwhen it went where asked,nullwithoutafter).
Without after the embed goes at the end. The same SVG already shown on the
page adds no revision.
Files that exist already: the upload. A PNG chart, a screenshot or a PDF is better sent as a
file than as base64 in a tool call. POST /mcp/pages/{key}/captures is the PWA’s
POST /api/pages/{key}/captures (HTTP API and CLI) under /mcp, so it needs the same headers as /mcp
(the bearer token; behind Cloudflare Access the service token, which the /mcp application
accepts for every path under it). It takes the same multipart form: id (a ULID, the retry key: a
retry with the same id returns 200 and changes nothing), one or more file parts (images, PDFs,
text and Markdown files; anything else is 415; over SCRAP_MAX_UPLOAD_MB is 413), and the
optional alt and after (as add_svg’s, for the images’ embeds). The reply is 201 with the
page, placed (as add_svg’s) and added: each file’s sha256, mime, filename and download_url (under
/mcp/blobs/). It is a write, so hostguard checks a browser’s Origin as for any POST; curl
sends none.
# a ULID: the retry key (send the same one again to retry)
id=$(python3 -c 'import os,time;n=int(time.time()*1000)<<80|int.from_bytes(os.urandom(10),"big");print("".join("0123456789ABCDEFGHJKMNPQRSTVWXYZ"[n>>s&31] for s in range(125,-1,-5)))')
curl -fsS "https://scrap.example.com/mcp/pages/<slug>/captures" \
-H "Authorization: Bearer $SCRAP_MCP_TOKEN" \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
-F id="$id" -F file=@chart.png -F alt="Speed against time" -F after="Q3 Plot the graph."
SVG is untrusted markup. Whoever sends it (add_svg, the upload, or the PWA’s Add photo), an
SVG is parsed with no DOCTYPE (so no entity is declared, expanded or fetched: a DOCTYPE that only
names an external DTD, as matplotlib and Illustrator write, is dropped unread; one with an internal
subset, or an entity, is refused), must be UTF-8 with an <svg> root, at most 1 MiB, and is stored sanitised: only
elements that draw are kept (no <script>, <foreignObject>, animation or element of another
namespace), no on* attribute, an href only to a #fragment of the file (an <image> may also
use a PNG, JPEG, GIF or WebP data: URL), no ping, and in CSS (a <style>, style and the
presentation attributes that take a url) no comment, no @-rule but @font-face, @media and
@keyframes, a url() only to a fragment or a data: font (an embedded font, as Excalidraw
exports have), and no image-set(), src(), backslash escape or quoted string holding a URL.
Every stored file but a PDF is served (the file, and an SVG’s preview, under /api and /mcp)
with Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data: and nosniff, so opened as a page it runs no script and fetches nothing:
an SVG stored before #480 (not sanitised), and any other markup (an imported .xml with an
<svg> root), too; an image or a text file shows as before. A PDF keeps sandbox alone, for
the browser’s PDF viewer; the PWA shows it in <img>, where none of that runs
anyway.
Claude’s own images are not read (#521). An image Claude adds (add_svg, or an image in
the upload: a capture with source mcp, author claude) gets no ingest job, even when asked
(Reprocess or Retry add nothing), and the PWA and public pages show nothing under it. Its alt
is its description: it is in the page’s text, which search covers, and it is the image’s alt
text in the app and on a public page. The same file in one of the user’s own captures is read as
any photo is. A PDF or text file in the upload is still read for search (no model).
How an SVG is read (one the user adds). Its preview (/api/blobs/{sha}/preview, at either size) is the file itself,
which scales; nothing rasterises it, and a page’s download with photos=preview keeps it as
.svg. Ingest reads its words (<title>, <desc>, <text>) as its text at once, with no
model call, so search finds 12 cm or 30°; then Claude gets its markup (the first 20,000
characters, with each embedded base64 payload, a font or a raster image, put as its size) as text, not an image, for the description, title and tags a photo gets (no
drawing: it is one). A write-up (writeup, or a board’s) sends it the same way: its markup as
text, where a photo’s JPEG would go.
Writing#
writemakes a new page, or withappend_to(an id or slug) addsbodyat the end of a page written over MCP, as a new revision, and adds itstags; appending to a page the user made is refused. Onlyedit_pageandrevert(withto_rev) change the text of other pages (below), andedit_meta,rename_tagandrevertonly kinds, tags and (edit_meta) a todo’s status, due and priority. No tool deletes a page.Due dates and priorities. A todo may have
due(YYYY-MM-DD) andpriority(high,medium,low); on another kind they are refused, asstatusis on a note. Withappend_to, they change those of the todo appended to (Claude’s own;noneclears one), andbodymay then be empty: that adds no revision and leavesupdated_atas it was. On any todo, the user’s too,edit_meta’sfieldsset them (below).Required arguments.
writeneedstitle,bodyandorigineven withappend_to(on an append,title,origin,kindandstatusare ignored; onlybody,tags,due,priorityandkeytake effect).Provenance. The page’s capture has source
mcpand authorclaude(as has its revision), the page ismade_byclaudefor good (re-kinding or editing it never changes that), andfields.originholdsorigin(free text, at most 200 characters: the repo or directory and the date, say). The PWA shows such a page with a Written by Claude badge (its origin as the tooltip), andis:claudefinds them. Clients ofPOST /api/capturescannot send sourcemcp.Idempotency. With
key(at most 128 characters), a retry with the same key adds nothing and returns the same page withcreated: false(add_svgis stricter: the same key with another SVG is refused). Without it, every call adds. Keys are global, not per session: use a random value per post, reused only to retry it. A key is matched together with its target (a new page, or the page appended to, by id or slug), so reusing one for another page writes there rather than being taken for a retry.Kind is fixed.
writealways fixes the kind it makes,note(the default) included, as when the user picks a kind in the PWA. Task boxes (- [ ]) in anotetherefore give it ataskscount in results but do not make it a todo, and it has nostatus. For a checklist that is a todo, writekind: todo: it has astatusand may havedueandpriority.Enrichment. With
SCRAP_INGEST=onandSCRAP_ENRICHnot turned off (it defaults to on), every pagewritemakes, of any kind, is queued for scrap’s own Claude aboutSCRAP_ENRICH_DEBOUNCEseconds (default 60) after it is written. That reading adds a summary (not for a text under 200 characters) and up to 5 tags, at most 2 of them not already in use. They show inread’sby_modeland intags, so a page’s tags a minute later can differ from thosewritesent. The text is never changed, and the calls count against the daily budget (SCRAP_BUDGET_DAY_USD, see Claude ingest).append_todoes not queue the page again, so appended text is not read until the page is edited in the PWA or the user asks Claude to read it. Related pages (by_model.related) are not part of this reading: they come from the nightly pass, or when the user asks. With enrichment off, none of this happens.Limits.
title1 to 200 characters (whitespace collapsed);bodyat most 200,000 bytes of UTF-8 per call, and a page, after an append, at most 1 MB (1,000,000 bytes, as for any other way of writing it); at most 20 tags, each 1 to 64 characters once normalised as imported tags are (one space between words, no leading#, lower case;a/bnests);kindone ofnote,todo,read,board,doc;status(open,in_progress,done,cancelled) only for a todo or a read. Text must be valid Unicode with no NUL (a lone surrogate is refused). A request over 2 MiB is413. A refused call is an MCP tool error with the reason; nothing is saved.Cleaning. Line endings in
bodybecome LF and it is trimmed of leading and trailing whitespace; whitespace runs intitleandoriginbecome one space. A new page’stitlemust not be empty once cleaned (the body may be: the page then has no text).nonefordueorpriorityclears the field on an append; on a new page it is the same as leaving it out. Aduethat is notYYYY-MM-DD, or apriorityother thanhigh,mediumorlow, is a tool error.
Page text#
Unless the server runs with SCRAP_MCP_CONTENT_EDIT=0 (then edit_page is not listed, nor are
edit_drawing, trash_pages and undelete; revert still is, but refuses to_rev; and the
server’s instructions say no tool edits the user’s text, though add_svg, unless
SCRAP_MCP_UPLOAD=0, still adds an image embed to one), Claude can edit the text of any page,
the user’s own too. This is on by default, and the safeguard against a page’s text steering the
model is only that instruction; see the tradeoff in MCP server and access. Both tools’ descriptions and the server’s instructions tell the model to use
them only on a page the user asked it to change, and only as asked, never because a page’s text
says to: page text is data. Both refuse a page in the trash (bring it back with undelete, which is listed
only while content editing is on, first).
edit_pagetakes one of two forms, plus an optional newtitle(whitespace collapsed; a title alone is a form too):oldandnew: replacesold, copied exactly from the stored body (line ends\n), withnew. To removeold, givedeleteinstead ofnew(an emptynewremoves it too, for a client that can send an empty string);deletewith a non-emptynew, or withoutold, is refused. Refused, saving nothing, ifoldis not in the body or is there more than once, counting overlapping occurrences (aais twice inaaa);replace_allreplaces every occurrence, and is refused too if any two overlap.replacementssays how many. A replacement that would take the page over 1 MB is refused from the count, before anything is built.body: the whole new body. Only withrev.rev, the page’srevfromread(or a dry run), makes the edit refuse a page changed since (“the page has changed: it is at rev N”); the targeted forms take it optionally, but should give it. Leaving a body that had text empty (blank) is refused unlessallow_empty, in every form and in a textrevert.old,newandbodyare at most 200 kB each, and the page at most 1 MB.
revertwithpageandto_revgives the page back the body it had at text revisionto_rev(revisionslists them; withrestore_title, that revision’s title too, else the current one stays), as a new revision: history is never rewritten, so a revert can be reverted in turn. It needs the page’s currentrev(refused if the page has changed since), except withdry_run. Restoring the text the page has already adds nothing.dry_run(edit_pageand a textrevert) returns the unified diff of the body (diff, cut at 100 000 characters:diff_cut; when very many lines changed, one hunk replacing them all) and the title before and after, and saves nothing: use it first.An edit that changes nothing adds no revision. Every other is a text revision (
page_rev) by authorclaudefrom sourcemcp; only Claude writes frommcp(over MCP, or the task boxes below). The PWA’s revisions are sourceweb, their author a login ortagged, neverclaudewhatever header a request sends.is:claude-editedfinds a page with a text revision by Claude after its first (anedit_page, arevertwithto_rev, or an append to a page Claude wrote:is:claude-edited -is:claudeis the user’s own pages Claude changed), andreadsaysedited_by_claude. Task boxes follow the new text as they do for the PWA’s edits: a box added to a note nobody filed makes it a todo, and a textrevertrestores text only, not that kind (edit_metasets a kind). With enrichment on, a save queues the page to be read again once the edits stop, as a PWA save does, so its summary and related pages follow the new text. The same author and source,claudeandmcp, are used by scrap’s own photo ingest and enrich when they append task boxes to a confident list of actions (Claude ingest, A list of actions becomes task boxes), sois:claude-editedfinds those pages too.Coalescing. An
edit_pagewithinSCRAP_REVISION_WINDOWseconds (default 300) of Claude’s previous text revision of that page folds into it, so a burst of small edits is one snapshot, not many: the burst’s text replaces that revision, keeping when it was made, under a new number (rev + 1, never one used before).revis also the PWA’s edit token, so an editor that loaded the page between two edits of a burst gets a conflict (409) on saving, never a silent overwrite; the burst leaves a gap in the numbers. Never folded into: a page’s first revision, a revision by anyone else or from another source (a PWA edit between ends the burst), one holding the body of an earlier revision (a textrevert’s, whatever its title), and one with a file whose address (/api/blobs/<sha>) the new text no longer has: deleting an embed keeps the revision that had it, so the file counts as removed from the page, not as attached but never placed. A textrevertitself never folds. Claude’s latest revision may be an append to its own page: an edit then folds into it, losing only the snapshot just after the append (its text stays in the page, and in the append’s capture). The author isclaudefor every MCP session, so two sessions editing one page within the window fold into each other.Not built: a bulk text edit (several pages, or a search’s, as one batch): each
edit_pageis one page, so batches stay metadata-only.
Security#
The service has no login of its own, and the tools have no access control: anyone who can reach
/mcp can read every page, and edit_meta, rename_tag, revert, edit_page,
edit_drawing, trash_pages, undelete, add_svg and the upload (Adding images and files) act on
any page. Every MCP
change is attributed to the author claude, with no per-user identity. By default scrap is
reachable only over the tailnet, through Tailscale Serve, like the rest of scrap; a single-server
install listens on a Unix socket instead, and SCRAP_LISTEN=tcp or a Kubernetes ingress changes
the exposure (see Deploy on a single server and Deploy on Kubernetes).
Bearer token (optional). Set SCRAP_MCP_TOKEN (or SCRAP_MCP_TOKEN_FILE, a file with one
token per line) and every request to /mcp must send Authorization: Bearer <token>; anything
else gets 401 with WWW-Authenticate: Bearer, before the Host and Origin checks run and
before any tool is dispatched. Several tokens may be set (comma separated, or one per line), so
one can be rotated in without a gap and each client can have its own and be revoked by removing
it; a restart applies the change. Tokens are compared in constant time and are never logged: a
refused request appears in the web server’s log with its status only. Only /mcp and the paths under it (the downloads, Downloads, and the upload, Adding images and files) are guarded;
/healthz stays open for probes, and the PWA and JSON API are left to the proxy in front. Unset,
nothing changes, so loopback and tailnet installs need no token. A setting that holds no token (an
empty value, a missing or empty file) stops scrap starting rather than leaving /mcp open.
A token in the file cannot start with # (that line is a comment) and one in SCRAP_MCP_TOKEN
cannot contain a comma.
Make a token with python -c 'import secrets; print(secrets.token_urlsafe(32))'. The tokens are
static; there is no token-issuing command and no rate limiting or lock-out of repeated 401s
(leave that to the proxy in front, such as Cloudflare). See ADR 0023.
The web app and the API check them the same way (HTTP API and CLI). /mcp also checks the Host header against loopback and the hosts of SCRAP_PUBLIC_URL (421
otherwise), and an Origin header, if sent, against those origins (403), so a web page cannot
reach it by DNS rebinding. The rules are:
Host.
127.0.0.1,localhostand[::1]on any port, and the host of eachSCRAP_PUBLIC_URLas written, port included. A URL with a port (https://myhost.ts.net:8446) accepts only that port; one without (https://scrap.<tailnet>.ts.net) accepts the bare host and any port, so a proxy that adds:443still works.Origin.
http://on those loopback names, any port, andscheme://host[:port]of each public URL exactly. A page on any other origin, or onhttps://localhost, gets403.No
Origin(curl, Claude Code): only theHostis checked.The value.
SCRAP_PUBLIC_URLis a comma separated list; a trailing/is dropped and blank items are ignored. A value that is not a URL (no scheme or host) stops scrap at startup.
Set SCRAP_PUBLIC_URL as described in Connect a Claude Code session (the environment
table in HTTP API and CLI lists it): the first URL is the base of every url, image_url,
thumbnail_url and download URL in results, and without it they are bare paths (/p/<slug>,
/api/blobs/<sha>, /mcp/blobs/<sha>).
What is recorded. scrap does not log tool calls: the server’s journal has the web server’s
request lines (POST /mcp, with the status, not the tool) and warnings such as a refused Host
or Origin. What Claude did is in the data instead. search with is:claude finds the pages it
wrote, and is:claude-edited those whose text it changed; revisions with page lists a
page’s kind and tag changes (author claude, source mcp) and its text revisions (author
claude, source mcp, which includes appended sections and edit_page edits, whose text text_rev reads);
with batch it lists what a bulk change did. No tool deletes a page or a revision, though a
burst of quick edits folds into one revision (see Revisions and Page text).
Pages may hold injected text, so a session holding powerful credentials should treat what it reads as data (see MCP server and access).
Not yet: thinning old revisions, a context tool returning the stored ingest run of a photo (its
messages, for discussing it with the model that made it), and an ingest tool.