MCP server and access#
scrap has no login of its own. The network in front is the gate, the identity header says who did
a thing, and /mcp adds an optional bearer token for clients that come in from outside.
Three ways in#
Every route ends at the same FastAPI app. What differs is the gate in front, where the author is attributed and where the bearer token is checked.
flowchart LR
subgraph single["Single server"]
TS["Tailscale Serve"] -->|"Unix socket, mode 600"| A1["scrap serve --uds"]
TS -.->|"opt-in: SCRAP_LISTEN=tcp"| L1["127.0.0.1:8790"]
L1 -.-> A1
end
subgraph k8s["Kubernetes"]
CF["Cloudflare Access<br/>+ service token"] --> IN["Ingress / Service"]
IN -->|"NetworkPolicy"| A2["scrap pod"]
end
subgraph dev["Local dev"]
L2["127.0.0.1:8000"] --> A3["scrap serve"]
end
A1 --> APP["FastAPI app<br/>author = identity header"]
A2 --> APP
A3 --> APP
APP --> MCP["/mcp and /mcp/... downloads<br/>bearer check"]
APP --> REST["PWA + JSON API<br/>no token"]
classDef user fill:#e9f7ef,stroke:#2e9d5b
classDef claude fill:#fff3e0,stroke:#e08a00
classDef store fill:#e8f1ff,stroke:#4a7bd0
class TS,CF user
class MCP claude
class APP store
Route |
Gate |
Who can connect |
Identity |
|
|---|---|---|---|---|
Single server |
Tailscale Serve (Funnel off) and the tailnet ACL |
the owner’s devices; locally only the scrap user, root and Serve (socket) |
|
optional |
Kubernetes |
Cloudflare Access with a service token |
whoever passes Access; the Service is a ClusterIP, a NetworkPolicy narrows who may reach the pod |
header from the proxy, if it sets one |
|
Local dev |
none; loopback only |
any local process |
none, so |
optional |
Unix socket.
scrap serve --uds PATHbinds a socket mode 600 in a folder that must be mode 700 and owned by the user (uds.py). Root and anything running as the scrap user can still open it and forge the header, so run scrap as its own user (ADR 0018).Loopback TCP.
SCRAP_LISTEN=tcppublishes127.0.0.1:$SCRAP_PORT(default 8790). It is opt-in for new installs, butdeploy/scrap-runtreats an unsetSCRAP_LISTENastcp, so env files from before the socket keep working. Any local process can reach it and send anyTailscale-User-Login. Barescrap servelistens on127.0.0.1:8000with the same caveat.Kubernetes.
networkPolicy.enabled(off by default) admits only thenetworkPolicy.frompeers on thehttpport, or nothing when the list is empty. The ServiceAccount does not mount its token (automountServiceAccountToken: false).
Chart defaults leave the cluster open#
The chart’s defaults favour a first install that works over safe-by-default: mcp.tokenSecret
is empty, so /mcp needs no token, and networkPolicy.enabled is false, so any pod in the
cluster can reach the whole API and /mcp and send any identity header. (The Host and
Origin checks stop other web pages, not other pods.) Where other workloads share the cluster,
set:
mcp:
tokenSecret:
name: scrap-mcp-token # an existing Secret; the key holds the bearer token
key: SCRAP_MCP_TOKEN
networkPolicy:
enabled: true
from: # only the proxy that fronts scrap
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: tailscale
and set SCRAP_PUBLIC_URL to the address you reach scrap at. The token guards /mcp only; the
NetworkPolicy is what keeps other pods off the API. See Deploy on Kubernetes.
Identity is attribution, not the gate#
authoris the request header named bySCRAP_IDENTITY_HEADER(defaultTailscale-User-Login), or, withSCRAP_OIDC_ISSUERset, the username (preferred_username) in the login proxy’s token (ADR 45). Without it the author istagged: tagged tailnet devices send none.Only the configured header is trusted. A proxy that sets another one (for example
X-Auth-Request-User) is named in that variable.MCP changes are always authored
claudefrom sourcemcp, whatever the caller;is:claude(pagesmade_byclaude) andis:claude-edited(text revisions from sourcemcp) find them. A web request is always sourceweb, and a header value ofclaudecounts as no header, so no request can pass for Claude (ADR 33).The header is trusted as sent. scrap does not check that the proxy set it, so a client that reaches scrap by any other path (a port-forward, a NodePort, another pod in the cluster, an ingress that does not strip it) chooses its own author name. It can name nobody as Claude, but the attribution is then worthless. Strip the header at every ingress that is not the identity proxy, and make the proxy the only way in.
The ACL is the gate. Tagged servers, such as any internet-facing box, must not be granted access to the scrap device (ADR 0007).
The MCP server#
/mcp is served by the official MCP SDK inside the app (Streamable HTTP, stateless, JSON replies),
so one claude mcp add --transport http line connects a client and a restart drops no session
(ADR 0013). There are fifteen tools;
arguments, limits and result shapes are in
MCP server.
flowchart LR
subgraph read["Read only"]
search
read_["read"]
tags
topics
drawing
end
subgraph write["Add pages"]
write_["write"]
end
subgraph meta["Kind and tags"]
edit_meta
rename_tag
end
subgraph text["Page text and drawings"]
edit_page
edit_drawing
end
subgraph hist["Revisions and trash"]
revisions
revert
trash
trash_pages
undelete
end
write_ --> R[("revisions")]
meta --> R
text --> R
revert --> R
trash_pages --> T[("trash")]
undelete --> T
trash_pages --> R
classDef store fill:#e8f1ff,stroke:#4a7bd0
classDef claude fill:#fff3e0,stroke:#e08a00
class R,T store
class edit_page,edit_drawing,trash_pages,undelete claude
Tools in orange are listed only while SCRAP_MCP_CONTENT_EDIT is on (default). revert with
to_rev also needs it; metadata revert does not.
Group |
Tools |
Notes |
|---|---|---|
Read |
|
results carry the PWA link |
Add |
|
a new page, or a section appended to a page written over MCP ( |
Kind and tags |
|
any page; bulk changes record a batch id; |
Text and drawings |
|
|
Images and files |
|
an SVG Claude writes, sanitised, embedded at the end or right after the line an exact anchor ends on (inside its list item or quote), as a text revision by |
History |
|
|
Trash |
|
the user’s deletes land in trash, as do pages the user asks Claude to delete ( |
What is not revisioned: Claude’s own enrichment (summary, model tags, related pages, board
write-ups, by_model). See Job queue and Claude features.
One write#
An MCP edit is a revision recorded under the author claude and source mcp, so a person can see
it and undo it.
sequenceDiagram
autonumber
participant C as Claude client
participant P as Proxy / Serve
participant B as /mcp bearer check
participant T as Tool
participant S as Store
C->>P: POST /mcp (Bearer token)
P->>B: forward, Host and Origin intact
B-->>C: 401 if token missing or wrong
B->>T: edit_page(page, old, new)
T->>S: write text, new page_rev (author claude)
S-->>T: rev id
T-->>C: result with PWA link and rev
Note over C,S: revert(page, to_rev) restores earlier text as another revision
The bearer check runs first, ahead of the
HostandOriginchecks and every tool; only/mcpand the paths under it are guarded. Tokens (SCRAP_MCP_TOKEN, comma separated, orSCRAP_MCP_TOKEN_FILE, one per line) compare in constant time and are never logged. A setting that yields no token stops startup rather than leaving/mcpopen.Then the transport checks
HostandOriginagainst loopback andSCRAP_PUBLIC_URL(DNS rebinding protection). Set it to the Serve address or clients get421or403.The web app and the API (every route but
/mcpand/healthz) checkHostand, on writes,Originthe same way (hostguard.py, #238), so another site cannot write through the owner’s browser.Downloads. An MCP client fetches files under
/mcp, by the/apiroutes’ own handlers (mcp_files.py, ADR 0041), since behind Cloudflare Access only/mcptakes the service token. They are read only and need the same headers as/mcp; the browser’s/apipaths are unchanged:Path
Gate
Same as
/mcpbearer, then the transport’s
HostandOrigin(the MCP endpoint)
GET /mcp/blobs/{sha},/preview,/usesbearer, then
Host(hostguard)GET /api/blobs/...GET /mcp/pages/{key}/downloadbearer, then
HostGET /api/pages/{key}/downloadGET /mcp/captures/{cid}/photos/{sha}/drawing,/versionsbearer, then
Hostthe
/apidrawing GETsGET /mcp/captures/{cid}/photos/{sha}/drawing.excalidrawbearer, then
Host(
/mcponly) the drawing as a filePOST /mcp/pages/{key}/captures(only withSCRAP_MCP_UPLOADon)bearer, then
Host, andOriginif sent (hostguard)POST /api/pages/{key}/captures, but Claude’s: authorclaude, sourcemcp/api/..., the PWAthe proxy (email login);
Host, andOriginon writesWith no token set they are open, as
/mcpis; behind Access the/mcpapplication’s path covers them. The one write, the upload (ADR 0042), is a separate list inmcp_files.py(WRITES), served only withSCRAP_MCP_UPLOADon.Limits per write: body 200 000 bytes, 20 tags, 200-character title, 200 pages per bulk
edit_meta, request 2 MB.
Untrusted content#
Pages, imported notes and fetched links are data, never instructions. Two boundaries keep it so.
flowchart TD
IN["Page text<br/>fetched link"] --> F["fetch.py<br/>public addresses only"]
F --> PR["Claude call<br/>fixed prompt, no tools"]
IN --> PR
PR -->|"JSON schema"| SAN["sanitise.py"]
SAN --> ST[("Store<br/>model fields only")]
ST --> OUT["MCP result<br/>quoted JSON fields"]
OUT --> CL["MCP client<br/>treats as data"]
classDef store fill:#e8f1ff,stroke:#4a7bd0
classDef claude fill:#fff3e0,stroke:#e08a00
classDef user fill:#e9f7ef,stroke:#2e9d5b
class ST store
class PR,CL claude
class IN user
Inbound. scrap calls Claude with a fixed prompt, no tools and a JSON schema, so there is nothing to call, no file to read and no shell. Output is checked and sanitised (
sanitise.py) and the user’s text is never edited. The worst case is a wrong title, tag, kind or summary, whichpage_meta_revandpage_revundo.Fetching.
fetch.pyrefuses private, loopback, tailnet and metadata addresses. It checks the address after DNS resolution and again at every redirect, then connects to the address it checked, with size and time limits.Outbound. Tool results return page text in JSON fields, and the server instructions tell the client it is the user’s data, quoted.
edit_pageandrevertare for pages the user asked to change, withdry_runavailable.Holds. The database, files and the Claude API key; no cluster or git credentials and no shell tools.
The content-edit tradeoff.
SCRAP_MCP_CONTENT_EDITis on by default, so an MCP client holdsedit_page,revert, bulkedit_metaandtrash_pagesnext toread. A page you captured (a web clip, an imported note) may contain text written to talk a model into using them, and the only defence is the instruction not to act on page text, which a model can still fail. The damage is bounded: every edit is a revision,revertundoes it, and trashed pages stay recoverable for 30 days. If you read pages you do not trust in a session with these tools, setSCRAP_MCP_CONTENT_EDIT=0(the edit tools are then not listed at all).SVG is markup. An SVG anyone uploads (
add_svg, the/mcpupload, the PWA’s Add photo, an import) is parsed with no DOCTYPE (one that only names the SVG DTD is dropped unread) and stored sanitised: no script,foreignObject, animation, event handler,ping, or link,url(),image-set()or quoted URL out of the file, no CSS @-rule but@font-face,@mediaand@keyframes(svg.py). Every stored file but a PDF is served withContent-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:andnosniff, and shown in<img>; its markup goes to Claude as text, quoted like any page text.Reading is not acting. A session that holds cluster or other powerful credentials should not mount the scrap MCP server, since pages may contain injected text.