Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<entity>/tags` work and are deliberately not
Expand Down
2 changes: 1 addition & 1 deletion src/tools/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down