From b3cb635c076be409b72a32c8838f6e4bd7720918 Mon Sep 17 00:00:00 2001 From: Anton Arapov Date: Thu, 11 Jun 2026 19:47:46 +0200 Subject: [PATCH] docs: document the inline-upload practical limit + sourceUrl design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attachment-upload limitation reported in production (a ~500 KB signed PDF can't be routed through the connector from a chat client) was documented nowhere — worse, the existing docs were misleading: DESIGN L9 and the dataBase64 description advertised the 25 MB cap, which is the server-side wire limit, while the real constraint for LLM callers is that inline base64 must transit the model as tool-call output (~660K characters for 500 KB — beyond any chat model's output budget at a few tens of KB practical). - upload_attachment.dataBase64 description: states the practical limit where the LLM actually reads it, and tells the model not to attempt inlining large files. - DESIGN.md L9: explains the structural bottleneck and which callers the 25 MB cap is real for (programmatic MCP clients). - EXAMPLES.md: the upload example carries the caveat + notes that chat-composer file attachments are not visible to MCP connectors. - IDEAS.md: full design for the planned remedy — URL-sourced upload (sourceUrl, mutually exclusive with dataBase64; server fetches the file) with the SSRF guard list (https-only, private/link-local IP rejection incl. metadata server, streaming 25 MB cap, no auth forwarding, 60s timeout) and the deployment-reachability constraint. Docs + one tool-description string; no behavioral change. 579 tests. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++++++++++++ DESIGN.md | 13 ++++++++++++ EXAMPLES.md | 8 ++++++-- IDEAS.md | 44 ++++++++++++++++++++++++++++++++++++++++ src/tools/attachments.ts | 2 +- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5967b4b..b78eb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ versions adhere to [Semantic Versioning](https://semver.org). ## [Unreleased] +### Changed + +- Documented the practical inline-upload limit honestly: the + `upload_attachment.dataBase64` description now tells LLM callers + that inline base64 is only viable for small files (a few tens of + KB — the bytes must transit the model as tool-call output) and not + to attempt large files; DESIGN.md L9 explains the structural + bottleneck (the 25 MB cap is the wire limit for programmatic + clients, not what a chat model can emit); EXAMPLES.md's upload + example carries the same caveat; IDEAS.md gains the full design for + the planned remedy, a URL-sourced upload (`sourceUrl`) with SSRF + guards. + ## [2.1.0] — 2026-06-11 Minor release: one additive field, wire-verified end-to-end (and the diff --git a/DESIGN.md b/DESIGN.md index 49f9fa8..37ea511 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -309,6 +309,19 @@ The HTTP transport's body limit is 35 MB by default (env-overridable via `MCP_HTTP_JSON_LIMIT`) so a 25 MB attachment fits with base64 expansion. Stdio has no body limit. +The 25 MB ceiling is the *wire* limit, not what an LLM caller can +practically send. `upload_attachment` takes the file inline as +base64 in a tool argument, which means the bytes must transit the +model as generated output: a ~500 KB PDF is ~660K base64 characters +(roughly 165k tokens) — beyond any chat model's output budget and +absurd in cost long before the wire limit matters. In practice, +inline upload through a chat client tops out at a few tens of KB. +The 25 MB cap is real only for *programmatic* MCP clients (n8n, +scripts) that construct the tool call directly. Raising server-side +limits cannot change this, and chunked upload wouldn't either (same +tokens, more calls). The planned remedy is a URL-sourced upload — +see IDEAS.md "URL-sourced attachment upload (`sourceUrl`)". + `upload_attachment` always creates a new note carrying the attachment. Adding an attachment to an *existing* entry isn't implemented — `update_entry` doesn't accept an attachments diff --git a/EXAMPLES.md b/EXAMPLES.md index af3bf0e..56a3a71 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -123,8 +123,12 @@ The placeholders are just to keep the examples concrete. return as metadata + base64 for downstream tools.) - Read the screenshot attached to the most recent note on $COMPANY. - Upload this PDF as a note on $COMPANY. (paste base64 contents - along with filename and content type, or attach the file in the - chat composer if your client supports it.) + along with filename and content type — practical only for small + files, a few tens of KB: the base64 must pass through the model + as tool-call output, so large files exceed the model's output + budget long before the connector's 25 MB limit. Files attached in + the chat composer are NOT visible to MCP connectors. See DESIGN.md + L9.) ## Quick diagnostics diff --git a/IDEAS.md b/IDEAS.md index cfc62da..4ed2f1c 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -261,6 +261,50 @@ common workflow involves passing PDFs through Claude. --- +## URL-sourced attachment upload (`sourceUrl`) + +`upload_attachment` requires the file inline as base64 in the tool +arguments, so the bytes must transit the model as generated output. +That caps practical uploads from chat clients at a few tens of KB — +a ~500 KB signed PDF is ~660K base64 characters (~165k tokens of +model output), impossible regardless of the server's 25 MB wire +limit (DESIGN.md L9). Raising any server-side limit cannot fix +this, and chunked upload doesn't either (same tokens, more calls). + +The remedy is to move the bytes out of the model's path: an +optional `sourceUrl` parameter, mutually exclusive with +`dataBase64`. The server fetches the file itself and uploads it to +Capsule; the model only ever handles a short URL. Workflow: put the +file anywhere https-fetchable (Drive "anyone with the link", +DocuSign signed URL, an internal file server the deployment can +reach), then "attach this URL to project X". + +Server-side URL fetch is an SSRF surface, so the implementation +needs the same guard discipline as the `CAPSULE_API_BASE_URL` +validation in `src/capsule/client.ts`: + +- https only; refuse redirects that downgrade the scheme +- resolve DNS and reject private / link-local / loopback ranges + (RFC 1918, 169.254.0.0/16 — the cloud metadata server is the + classic SSRF target), re-checking on every redirect hop +- enforce the existing 25 MB cap while streaming (abort + mid-download — same pattern as `capsuleGetBinary`'s streaming cap) +- take `Content-Type` from the response, caller-overridable; never + forward auth headers to the fetched URL +- standard 60 s request timeout + +Constraints to document on the tool when shipped: the URL must be +reachable from the deployment without auth (a private Drive file +won't work; a signed/public link will), and files attached to a +Claude chat are NOT exposed to MCP connectors, so there is no path +to "upload the file I just attached to this conversation". + +**When to consider**: a real request already exists (attaching a +~500 KB signed contract PDF to a Lifecycle project, 2026-06-11). +Effort: ~half a day with tests. + +--- + ## Tag CRUD `POST/PUT/DELETE` on `//tags` work and are deliberately not diff --git a/src/tools/attachments.ts b/src/tools/attachments.ts index 55c13ad..99a1f8c 100644 --- a/src/tools/attachments.ts +++ b/src/tools/attachments.ts @@ -99,7 +99,7 @@ export const uploadAttachmentSchema = z.object({ .min(1) .max(HARD_MAX_BASE64_CHARS) .describe( - "File contents, base64-encoded. Decoded server-side and uploaded as the request body. Maximum 25 MB per attachment (Capsule's documented limit); the connector rejects oversized base64 before uploading. The inbound HTTP body limit is ~35 MB which leaves room for the base64 expansion of a 25 MB binary.", + "File contents, base64-encoded. Decoded server-side and uploaded as the request body. PRACTICAL LIMIT: the base64 must be produced inline as tool-call output, so uploads driven by an LLM are only viable for small files (a few tens of KB) — a 500 KB file is ~660K characters, far beyond a chat model's output budget. Do not attempt to inline large files; tell the user the file is too large to route through the model. The 25 MB maximum (Capsule's documented limit) applies to programmatic MCP clients that construct the call directly; the connector rejects oversized base64 before uploading.", ), content: z .string()