42. MCP clients add SVG diagrams and files to pages#
Date: 2026-10-31 (#480)
Status#
Accepted
Context#
An MCP client could read, edit and (with ADR 0041) download, but not add an image or a file to a page. A diagram for a maths question was ASCII art; a chart or a screenshot Claude made elsewhere could not live in scrap.
Two kinds of file need two routes. SVG is text: Claude can write an exact, labelled diagram for about the cost of a paragraph, so a tool argument suits it. A PNG, a PDF or a scan already on disk is binary, and base64 through a tool call is slow, costly and error-prone.
Writing is a step up from downloading, and SVG is markup that a browser can run if it is opened directly.
Decision#
add_svg, an MCP tool. It takes the page, the markup, alt text, an optional file name, an optionalafter(text that must occur exactly once, asedit_page’sold), akeyanddry_run. The embed goes right after the lineafterends on: a paragraph is split there; in a tight list (the owner’s decision) the line ends with a hard break (\) and the embed follows on the next line at the item’s indent, so the list stays tight; in a loose list or a quote it is a paragraph of its own in the item or quote. A block that cannot be split (a fenced or indented code block, a table,$$or\begin{...}maths, a heading) has it after its end. The text is parsed as the page is rendered to find the block (store/placing.py, tabs to stops of four), and each place tried is parsed again: kept only if the page’s blocks, the item or quote the embed is in, the lists’ tightness and the rendered text are unchanged (a split that would start a list, or cut a link or bold text in two, falls back to the end of the paragraph); if no place passes, the embed goes at the end of the text. So a diagram sits under its question. Withoutafterit goes at the end. The samekeywith another SVG is refused,dry_runtoo. It stores the SVG as a new capture of the page and embeds it as the PWA’s Add photo does (store.add_photos), with the alt text.The upload, over HTTP.
POST /api/pages/{key}/capturesis also served atPOST /mcp/pages/{key}/captures, by the same handler, soBearerAuthand the Cloudflare Access application on/mcpgate it with the headers a client already sends. It keeps the ULID retry key,415and413, and gainsalt,afterand anaddedlist of download URLs.mcp_fileslists it apart from the GET mirrors (WRITES), and a missing route still stops startup.Both are Claude’s. A request by an
/mcp/...path is attributed toclaudewith sourcemcp(uploads.by_mcp), and the embed is a text revision with sourcemcp, asadd_svg’s is:is:claude-editedfinds it,revisionslists it,revert(page, to_rev)takes the embed out. Both wake the ingest worker, as a PWA upload does.One setting.
SCRAP_MCP_UPLOAD, on by default, parsed asSCRAP_MCP_CONTENT_EDITis (0,false,nooroffturns it off; any other value stops startup). Off,add_svgis not listed and the route is not served. It is the only gate:SCRAP_MCP_CONTENT_EDIT=0does not limit either (adding an image is not editing the user’s text; the owner’s decision).SVG is sanitised on every upload (
svg.py;add_svg, the/mcpupload, the PWA, a Markdown import, which reports one it refuses and imports the rest). It must be UTF-8, at most 1 MiB, with an<svg>root. It is parsed by the standard library with no DOCTYPE (one naming only an external DTD, as matplotlib writes, is dropped unread; one with an internal subset is refused), so no entity can be declared, expanded or fetched (no new dependency). Only an allow-list of drawing elements is kept, with noon*attribute, nohrefbut to a#fragment(or a rasterdata:URL on an image), noping, and in CSS (<style>,styleand the presentation attributes that take a url) no comment, no @-rule but@font-face,@mediaand@keyframes(another becomes@x-..., which a browser ignores), nourl()but to a fragment or adata:font (each one checked on its own), and noimage-set(),src(), escape or quoted URL; the passes repeat until nothing changes, so no removal can join what is left into a rule.And a CSP that loads nothing. Every stored file but a PDF, and an SVG’s preview too, under
/apiand/mcp, hasContent-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:andnosniff(STRICT_HEADERS): opened as a page it runs no script and fetches nothing, even if the sanitiser misses something, the file predates it, or the file is other markup (an imported.xmlwith an<svg>root). An image or a text file shows as before; a PDF keepssandboxalone, as a browser’s PDF viewer needs more. In<img>, where the PWA shows it, an SVG runs nothing and loads nothing anyway.An SVG is never rasterised.
/api/blobs/{sha}/previewserves the SVG itself at either size, with the same headers; nothing decodes it with Pillow (preview, Claude’s JPEG, a page’s download with previews).Read as text. Ingest puts its words (
<title>,<desc>,<text>) as the image’s text at once, with no model call, as a PDF’s text layer is read; then Claude reads its markup (the first 20,000 characters) as a text block instead of an image, for the description, title and tags, with no drawing asked for. A write-up (a board’s, or one asked for) sends an SVG image the same way: its markup as text blocks, where a photo’s JPEG would go. The markup is untrusted data, quoted, as page text is. Since 1.3.3 this is so only for an SVG the owner adds: one Claude adds (add_svg, the/mcpupload) is not read at all, and its alt is what search finds (ADR 44).
Consequences#
A diagram can go exactly where it belongs, and its labels are searchable without a model.
An SVG uploaded from the PWA now shows, has a preview and is read, where before its preview failed and its ingest was charged as an undecodable image. It is stored sanitised, so the file kept is not byte for byte the one sent; a malformed SVG, one with an internal DTD subset or entities, or one over 1 MiB is refused.
A gist renders SVG from its raw URLs (
gist.githubusercontent.com/.../raw/file.svg) andgh gist createtakes it as a text file, so a page with SVG diagrams can be published as a gist of text files alone, with no clone or push for binaries. Update (2026-10-31): removed by 43. Public pages: a static site scrap pushes to, with the scrap-to-gist skill; public pages are planned to go to a static site instead.A client with the MCP headers can add files to any page, unless
SCRAP_MCP_UPLOAD=0.An SVG stored before #480 was not sanitised;
STRICT_HEADERSstill keeps it from running or fetching anything when it is opened.