# 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. ```{contents} :local: :depth: 1 ``` ## 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. ```mermaid 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
+ 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
author = identity header"] A2 --> APP A3 --> APP APP --> MCP["/mcp and /mcp/... downloads
bearer check"] APP --> REST["PWA + JSON API
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 | `/mcp` token (and `/mcp/...`) | | --- | --- | --- | --- | --- | | Single server | Tailscale Serve (Funnel off) and the tailnet ACL | the owner's devices; locally only the scrap user, root and Serve (socket) | `Tailscale-User-Login` set by Serve | 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 | `SCRAP_MCP_TOKEN`, recommended | | Local dev | none; loopback only | any local process | none, so `tagged` | optional | - **Unix socket.** `scrap serve --uds PATH` binds 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](../decisions/0018-unix-socket-for-single-server-installs.md)). - **Loopback TCP.** `SCRAP_LISTEN=tcp` publishes `127.0.0.1:$SCRAP_PORT` (default 8790). It is opt-in for new installs, but `deploy/scrap-run` treats an unset `SCRAP_LISTEN` as `tcp`, so env files from before the socket keep working. Any local process can reach it and send any `Tailscale-User-Login`. Bare `scrap serve` listens on `127.0.0.1:8000` with the same caveat. - **Kubernetes.** `networkPolicy.enabled` (off by default) admits only the `networkPolicy.from` peers on the `http` port, 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: ```yaml 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 [](../../how-to/deploy-kubernetes.md). ## Identity is attribution, not the gate - `author` is the request header named by `SCRAP_IDENTITY_HEADER` (default `Tailscale-User-Login`), or, with `SCRAP_OIDC_ISSUER` set, the username (`preferred_username`) in the login proxy's token ([ADR 45](../decisions/0045-scrap-verifies-the-login-proxys-keycloak-token.md)). Without it the author is `tagged`: 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 `claude` from source `mcp`, whatever the caller; `is:claude` (pages `made_by` `claude`) and `is:claude-edited` (text revisions from source `mcp`) find them. A web request is always source `web`, and a header value of `claude` counts as no header, so no request can pass for Claude ([ADR 33](../decisions/0033-who-made-a-page-is-stored.md)). - 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](../decisions/0007-tailscale-serve-for-access-and-identity.md)). ## 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](../decisions/0013-mcp-server-inside-the-app.md)). There are fifteen tools; arguments, limits and result shapes are in [](../../reference/mcp.md). ```mermaid 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 | `search`, `read`, `tags`, `topics`, `drawing` | results carry the PWA link `$SCRAP_PUBLIC_URL/p/`; `read` adds `by_model`, labelled as model-written; a trashed page reads as `trashed: true` | | Add | `write` | a new page, or a section appended to a page written over MCP (`append_to`); source `mcp`, author `claude` | | Kind and tags | `edit_meta`, `rename_tag` | any page; bulk changes record a batch id; `dry_run` first ([ADR 0019](../decisions/0019-kinds-and-tags-editable-with-revisions.md)) | | Text and drawings | `edit_page`, `edit_drawing` | `edit_page`: one passage found exactly once, or the whole body given its current `rev` ([ADR 0022](../decisions/0022-page-text-editable-over-mcp-with-revisions.md)); `edit_drawing`: a new current version of an image's drawing, given the `base_version` read ([](../../reference/mcp.md)) | | Images and files | `add_svg` (and the upload below) | 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 `claude`; listed only while `SCRAP_MCP_UPLOAD` is on (default), whatever `SCRAP_MCP_CONTENT_EDIT` ([ADR 0042](../decisions/0042-mcp-clients-add-svg-and-files.md)) | | History | `revisions`, `revert` | `revert` undoes metadata by revision or batch, or text with `to_rev` | | Trash | `trash`, `trash_pages`, `undelete` | the user's deletes land in trash, as do pages the user asks Claude to delete (`trash_pages`, at most 50 a call, as a batch `undelete` brings back); nothing over MCP deletes for good or empties the trash ([ADR 0025](../decisions/0025-trash-and-recent-changes-over-mcp.md), [ADR 0034](../decisions/0034-claude-can-move-pages-to-the-trash.md)); every move is in `revisions`; `trash` gives each page's `purge_at`, as nightly gc purges it after N days ([ADR 0030](../decisions/0030-trash-purged-after-n-days.md)) | What is not revisioned: Claude's own enrichment (summary, model tags, related pages, board write-ups, `by_model`). See [](claude-pipeline.md). ### 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. ```mermaid 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 `Host` and `Origin` checks and every tool; only `/mcp` and the paths under it are guarded. Tokens (`SCRAP_MCP_TOKEN`, comma separated, or `SCRAP_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 `/mcp` open. - Then the transport checks `Host` and `Origin` against loopback and `SCRAP_PUBLIC_URL` (DNS rebinding protection). Set it to the Serve address or clients get `421` or `403`. - The web app and the API (every route but `/mcp` and `/healthz`) check `Host` and, on writes, `Origin` the 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 `/api` routes' own handlers (`mcp_files.py`, [ADR 0041](../decisions/0041-mcp-clients-download-under-mcp.md)), since behind Cloudflare Access only `/mcp` takes the service token. They are read only and need the same headers as `/mcp`; the browser's `/api` paths are unchanged: | Path | Gate | Same as | | --- | --- | --- | | `/mcp` | bearer, then the transport's `Host` and `Origin` | (the MCP endpoint) | | `GET /mcp/blobs/{sha}`, `/preview`, `/uses` | bearer, then `Host` (`hostguard`) | `GET /api/blobs/...` | | `GET /mcp/pages/{key}/download` | bearer, then `Host` | `GET /api/pages/{key}/download` | | `GET /mcp/captures/{cid}/photos/{sha}/drawing`, `/versions` | bearer, then `Host` | the `/api` drawing GETs | | `GET /mcp/captures/{cid}/photos/{sha}/drawing.excalidraw` | bearer, then `Host` | (`/mcp` only) the drawing as a file | | `POST /mcp/pages/{key}/captures` (only with `SCRAP_MCP_UPLOAD` on) | bearer, then `Host`, and `Origin` if sent (`hostguard`) | `POST /api/pages/{key}/captures`, but Claude's: author `claude`, source `mcp` | | `/api/...`, the PWA | the proxy (email login); `Host`, and `Origin` on writes | | With no token set they are open, as `/mcp` is; behind Access the `/mcp` application's path covers them. The one write, the upload ([ADR 0042](../decisions/0042-mcp-clients-add-svg-and-files.md)), is a separate list in `mcp_files.py` (`WRITES`), served only with `SCRAP_MCP_UPLOAD` on. - 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. ```mermaid flowchart TD IN["Page text
fetched link"] --> F["fetch.py
public addresses only"] F --> PR["Claude call
fixed prompt, no tools"] IN --> PR PR -->|"JSON schema"| SAN["sanitise.py"] SAN --> ST[("Store
model fields only")] ST --> OUT["MCP result
quoted JSON fields"] OUT --> CL["MCP client
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, which `page_meta_rev` and `page_rev` undo. - **Fetching.** `fetch.py` refuses 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_page` and `revert` are for pages the user asked to change, with `dry_run` available. - **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_EDIT` is on by default, so an MCP client holds `edit_page`, `revert`, bulk `edit_meta` and `trash_pages` next to `read`. 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, `revert` undoes it, and trashed pages stay recoverable for 30 days. If you read pages you do not trust in a session with these tools, set `SCRAP_MCP_CONTENT_EDIT=0` (the edit tools are then not listed at all). - **SVG is markup.** An SVG anyone uploads (`add_svg`, the `/mcp` upload, 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`, `@media` and `@keyframes` (`svg.py`). Every stored file but a PDF is served with `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:` and `nosniff`, and shown in ``; 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. ## See also - [Architecture](../design.md) and [](claude-pipeline.md), [](deployment.md) - [](../../reference/mcp.md), [](../../how-to/connect-claude-code.md), [](../../how-to/deploy-kubernetes.md), [](../../how-to/deploy-single-server.md)