Let upload_image take a local file, not only an http(s) URL - #22
Merged
Conversation
`upload_image` accepted a URL and nothing else, so an image generated or edited on the machine running this server could not be uploaded at all: it had to be published somewhere public first, or the whole flow abandoned for the browser. That limit was self-imposed. `POST /api/v1/image` only ever wanted a data URI, so where the bytes came from was never its business. `path` is now the alternative to `url`, exactly one per call, backed by `readImageFileAsDataUri` in `src/api/substack/image.js` — the sibling of `fetchImageAsDataUri`, sharing its last step so the two sources cannot drift. Verified live against implementing.substack.com end to end: a PNG generated locally, uploaded by path, written to a new draft's cover_image and read back with get_draft, with cover_image_rehosted_from null proving the Substack host was recognised and the url stored unchanged. The local branch does not inherit three things from the URL one: - The content type comes from the magic bytes, never the extension. A local file carries no Content-Type, and the extension is the caller's claim rather than evidence: a PDF renamed .png would otherwise reach Substack and come back as a 400 naming neither the file nor the reason. PNG, JPEG, GIF and WebP are recognised; an ISO-BMFF ftyp box with a HEIC/HEIF brand gets the same convert-first message the URL path gives. Five bounded signatures, written out rather than taken as a dependency — the argument csv.js already makes. - The path must be absolute. A relative one resolves against this server's cwd, not the calling client's, so it would read the wrong file or none, and neither failure would say why. realpath runs first, so a symlink is followed to the file actually read and `..` cannot mean one thing at the check and another at the read. - The size cap is enforced on stat.size before the file is read, the local mirror of the Content-Length pre-check in readCapped. There is deliberately no filesystem allowlist. An env-var root was designed and dropped: a server that needs configuring before `path` works at all is a server back where it started. What it means is stated in CLAUDE.md and the README instead — the caller is an LLM, so this is a read of an arbitrary local file whose bytes then leave for Substack. The exclusivity rule is enforced twice on purpose. The `.superRefine` is the runtime half; a refinement does not survive z.toJSONSchema, so the rule is also written into both property descriptions, and a test pins that. Adding the source meant editing three descriptions, and the third was nearly missed: the tool's own `description` in the `tools` registry is what a model reads to choose which tool to call, while the property descriptions are only reached once it has already chosen. A pitch still saying "from an http(s) URL" left `path` present in the schema and invisible in the offer — exactly how a model concludes a local file cannot be uploaded and reaches for the browser. server.spec.js now asserts the pitch names both sources. 748 tests pass on Node 22 (the engines floor) and 24 (.nvmrc). Each new assertion was mutation-checked: breaking the sniff, the absolute-path guard, the size pre-check, the descriptions and the XOR rule each turns red exactly the tests that claim to cover them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code review flagged the asymmetry with `readCapped`, which enforces the cap in two places and says so in a comment. The local branch checks only `stat.size`, and the comment called itself "the local mirror of the Content-Length pre-check" without noting that the other half is missing on purpose — which reads as an oversight rather than a decision. It is a decision. `readCapped` re-checks the buffered length because Content-Length is a claim made by an untrusted remote server, which may declare a small length and send more. `stat.size` is the kernel's answer about the file realpath just resolved; there is no second party to disagree with it. The window between the stat and the readFile is a real residual and is now named as one, on the same terms as the DNS-rebinding note above it: the adversary it would buy protection from already has write access to this machine's disk. No behaviour change — comment in image.js and the matching bullet in CLAUDE.md. This repo states its accepted residuals rather than leaving them implicit, and this one was the exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
upload_imageaccepted a URL and nothing else, so an image generated or edited on the machine running this server could not be uploaded at all — it had to be published somewhere public first, or the flow abandoned for the browser. The limit was self-imposed:POST /api/v1/imageonly ever wanted a data URI, so where the bytes came from was never its business.What changed
pathis the alternative tourl, exactly one per call, backed byreadImageFileAsDataUriinsrc/api/substack/image.js— the sibling offetchImageAsDataUri, sharing its last step so the two sources cannot drift.The local branch does not inherit three things from the URL one:
Content-Type, and the extension is the caller's claim rather than evidence: a PDF renamed.pngwould otherwise reach Substack and come back as a 400 naming neither the file nor the reason. PNG, JPEG, GIF and WebP are recognised; an ISO-BMFFftypbox with a HEIC/HEIF brand gets the same convert-first message the URL path gives. Five bounded signatures, written out rather than taken as a dependency — the argumentcsv.jsalready makes.realpathruns first, so a symlink is followed to the file actually read and..cannot mean one thing at the check and another at the read.stat.sizebefore the file is read, the local mirror of theContent-Lengthpre-check inreadCapped.update_draft'scover_imagedeliberately still takes only a URL. The flow isupload_image({path})→ the S3 url →update_draft({cover_image: <that url>}), which recognises the Substack host and writes it unchanged. Two doors onto one thing would be one too many.What a reviewer should know
There is no filesystem allowlist, and that was a decision. An env-var root was designed and dropped: a server that needs configuring before
pathworks at all is a server back where it started. What it means is stated inCLAUDE.mdand the README instead — the caller is an LLM, so this is a read of an arbitrary local file whose bytes then leave for Substack. Worth pushing back on if you disagree; it is the one judgement call here.The exclusivity rule is enforced twice on purpose. The
.superRefineis the runtime half. A refinement does not survivez.toJSONSchema, so the rule is also written into both property descriptions, and a test pins that — same reasoning as the one-paywall rule indocument.js.Adding a source meant editing three descriptions, and the third was nearly missed. The tool's own
descriptionin thetoolsregistry is what a model reads to choose which tool to call; the property descriptions are only reached once it has already chosen. A pitch still saying "from an http(s) URL" leftpathpresent in the schema and invisible in the offer — exactly how a model concludes a local file cannot be uploaded and reaches for the browser.server.spec.jsnow asserts the pitch names both sources.SVG is refused by the sniff (
3c 73 76 67). WhetherPOST /api/v1/imagewould acceptimage/svg+xmlwas never measured: the dashboard builds its uploads fromcanvas.toDataURL(), which cannot produce one.Verification
Live, end to end against
implementing.substack.comthrough the real MCP server:upload_image({path})1500x1000read back by Substackcreate_draft_post→update_draft({cover_image})updated_fields: ["cover_image"],cover_image_rehosted_from: nullget_drafturl+pathcover_image_rehosted_from: nullis the one that matters: the Substack host was recognised and the url stored unchanged, rather than downloaded and re-uploaded.748 tests pass on Node 22 (the
enginesfloor) and 24 (.nvmrc). Each new assertion was mutation-checked — breaking the sniff, the absolute-path guard, the size pre-check, the descriptions and the XOR rule each turns red exactly the tests that claim to cover them. One mutation initially failed to match its regex and left the file untouched; caught by grepping for the marker before trusting the run, perCLAUDE.md.🤖 Generated with Claude Code