Skip to content

Let upload_image take a local file, not only an http(s) URL - #22

Merged
marcomoauro merged 2 commits into
mainfrom
upload-image-local-file
Aug 9, 2026
Merged

Let upload_image take a local file, not only an http(s) URL#22
marcomoauro merged 2 commits into
mainfrom
upload-image-local-file

Conversation

@marcomoauro

Copy link
Copy Markdown
Owner

Why

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 flow abandoned for the browser. The limit was self-imposed: POST /api/v1/image only ever wanted a data URI, so where the bytes came from was never its business.

What changed

path is 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.

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.

update_draft's cover_image deliberately still takes only a URL. The flow is upload_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 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. Worth pushing back on if you disagree; it is the one judgement call here.

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 — same reasoning as the one-paywall rule in document.js.

Adding a 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; 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.

SVG is refused by the sniff (3c 73 76 67). Whether POST /api/v1/image would accept image/svg+xml was never measured: the dashboard builds its uploads from canvas.toDataURL(), which cannot produce one.

Verification

Live, end to end against implementing.substack.com through the real MCP server:

Step Result
PNG generated locally, upload_image({path}) 200, S3 url, 1500x1000 read back by Substack
create_draft_postupdate_draft({cover_image}) updated_fields: ["cover_image"], cover_image_rehosted_from: null
get_draft cover persisted, confirmed by an independent read
relative path / missing file / SVG / url+path each refused with a message naming the cause

cover_image_rehosted_from: null is 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 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. One mutation initially failed to match its regex and left the file untouched; caught by grepping for the marker before trusting the run, per CLAUDE.md.

🤖 Generated with Claude Code

marcomoauro and others added 2 commits August 9, 2026 18:53
`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>
@marcomoauro
marcomoauro merged commit 99e366d into main Aug 9, 2026
2 checks passed
@marcomoauro
marcomoauro deleted the upload-image-local-file branch August 9, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant