feat: upload files through the MCP server for action File fields - #1815
Conversation
12 new issues
|
|
|
||
| // Swap the upload handles for the uploaded files before they reach the form. agent-client | ||
| // encodes them for the agent. No-op when no value carries a handle. | ||
| const values = options.values |
There was a problem hiding this comment.
🟠 High tools/execute-action.ts:66
resolveUploadedFileValues inspects every field value, so a legitimate string or text field value that happens to start with $uploadedFile: is treated as an upload handle. The value is then replaced or rejected during handle verification, making it impossible to submit that literal text through the action. There is no field-type check or escaping mechanism to distinguish real upload handles from ordinary string values with the same prefix. Consider only resolving handles for fields whose schema type is a file/upload, or providing an escaping mechanism so literal values with this prefix can pass through unchanged.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/mcp-server/src/tools/execute-action.ts around line 66:
`resolveUploadedFileValues` inspects every field value, so a legitimate string or text field value that happens to start with `$uploadedFile:` is treated as an upload handle. The value is then replaced or rejected during handle verification, making it impossible to submit that literal text through the action. There is no field-type check or escaping mechanism to distinguish real upload handles from ordinary string values with the same prefix. Consider only resolving handles for fields whose schema type is a file/upload, or providing an escaping mechanism so literal values with this prefix can pass through unchanged.
There was a problem hiding this comment.
Real, but not fixing it here — the tradeoff is deliberate.
A field-type check is not available at this point: resolution runs before the form is loaded, so field types are unknown. Resolving after action() loads the form would give us types, but it moves the download inside the form lifecycle for a collision that requires a user to submit the literal text $uploadedFile:<something> into a text field.
An escape hatch has the opposite problem: an unverifiable reference would have to pass through silently, and then a genuinely expired or tampered handle reaches the agent as a raw string instead of producing "handle expired". That trades a readable error for silent corruption, which is the failure mode this PR spent most of its effort removing.
The related and more likely mistake — a resolved file landing on a field that is not a file — is now rejected in agent-client (encodeFileFieldValue throws instead of serializing {"buffer":{"type":"Buffer",...}} into the column).
This becomes clean once SEP-2631 lands: the file input is declared in the schema (x-mcp-file), so resolution can key off the declaration instead of a string prefix. Noted as a known limitation until then.
9485a7d to
fd8652d
Compare
The data uri codec used for action File fields was duplicated in the agent and in plugin-aws-s3. Expose it here so both, and agent-client, share one implementation. Empty media types are now filtered out, which the agent copy did not do (it emitted charset=undefined). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setFields was a pass-through, so every caller had to hand-craft the
data uri the agent expects for a File field. It now accepts a File object
({ buffer, mimeType, name }) and encodes it, while strings pass through
untouched so already encoded data uris and opaque references still work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent and plugin-aws-s3 each carried their own copy of the same parser and encoder, and plugin-aws-s3 redeclared the File type. Both now use the datasource-toolkit implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Action forms with File fields could not run over MCP: the agent expects a data uri, so the bytes had to travel through the model's context window and exceeded most clients' payload limits. POST /files returns a pre-authorized upload URL plus a signed handle bound to the requesting user, the client uploads straight to the storage backend, and executeAction swaps the handle for the uploaded file before calling the agent. The model only ever exchanges the small handle. Redemption enforces a size cap, an optional sha256 pin, and a download concurrency bound. The storage backend is pluggable and adds no dependency to the package. Co-authored-by: Stefano Amorelli <stefano@amorelli.tech> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PlainField.type widening broke agent-bff and workflow-executor; the wire array form is now normalized in FieldGetter.getType instead, which keeps the public signature a string. loadChanges still echoes the wire shape verbatim, which the agent's change-hook parser requires. Silent failures found in review: - a maxConcurrentDownloads below 1 queued every redemption with nothing left to release it, hanging instead of failing; the numeric options are now validated at startup - getSize answering null or NaN skipped the size cap, since strictNullChecks is off - a single file on a FileList field, and a file on a field that is not one, were shipped unencoded and serialized into the column - parseDataUri raised an opaque TypeError on anything that was not a data uri - resolution ran outside withActivityLog, so a cross-user redemption attempt left no audit trail Also: verify handles before taking a download slot, validate the claims that feed storage.download, pin the JWT algorithm, name the field in redemption errors, reject a filename made only of dots, and plumb fileUploads through mountAiMcpServer so the option is reachable on the embedded mount. The README claim that maxConcurrentDownloads bounds memory was wrong: every file a call references is held until it completes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- the embedded mount never dispatched /files: makeIsMcpRoute was built
without the fileUploads flag, so the option was plumbed but the route
still 404ed, leaving the feature unusable on agent.mountAiMcpServer
- isFile accepted { buffer, mimeType } with no name, encoding a data uri
without name= and producing a File whose required name was missing
- warn when handleTtlSeconds equals uploadUrlTtlSeconds, which leaves zero
margin to redeem, not only when it is shorter
getType() now answers 'StringList' where it used to leak the wire array
['String'], so the agent-testing expectation follows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
POST /files was invisible to clients: the only thing telling a model it existed was a sentence in the executeAction description. A tool is listed by tools/list with its schema, so the client discovers it, and the call goes through the activity log like every other tool. The tool is registered only when a storage backend is configured, so a server without one never advertises a capability every call would reject. It also carries the prerequisite the server cannot check: the upload is an outbound HTTPS request made by the client. In a code execution sandbox the host of uploadUrl must be allowed for outbound traffic — on Claude Desktop under Additional allowed domains. Stated in the description and repeated in the response, so a model whose upload was blocked can tell that apart from an expired handle. Removes the express router, the /files route matching and its plumbing in the agent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolveFileUploads validates five options and warns on a handle that cannot outlive its upload window, none of which was tested. The upload destination had no coverage of the method/headers fallbacks either. Also drops a dead branch in resolve: download never used the parsed reference, so collectReferences now carries the handle instead of the value being parsed twice, and the unreachable kind guard is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manual end-to-end testing against a real agent surfaced two errors the caller could not act on. A non-data-uri on a File field threw a bare Error, which the agent's error middleware renders as a generic 500 — so a model that passed a filename instead of a handle learned nothing. It is now a ValidationError, which maps to a 400 carrying the message. And the friendly empty-file hint was unreachable: a backend honouring the contract rejects a missing object rather than returning zero bytes, so the most likely mistake — redeeming a handle that was never uploaded to — surfaced as a raw NoSuchKey. The download failure now names the field and keeps the cause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flow had no runnable demonstration: exercising it required a cloud bucket and a smart action with a File field, so nobody could try it without building both. _example now carries a disk-backed UploadStorage that serves its own PUT endpoint, and the review collection gets an 'Attach a document' action with a File and a FileList field, reporting the name, mime type and byte count it received — which is what tells you the bytes crossed the chain intact. The storage authenticates nothing and is for localhost only; its objects are gitignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PUT key comes from the request URL, so it is caller-controlled, and sanitizing the charset was not enough: '.' and '/' are legal in a key, so '../../etc/passwd' resolved outside the storage root and the unauthenticated endpoint wrote there. Reproduced before the fix, rejected with a 400 after. pathOf now resolves then requires containment, and it runs inside the promise chain: throwing from the 'end' listener would have taken the process down instead of answering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… every object Moving POST /files to a tool silently dropped an authorization check: the route was mounted behind mcp:action, while /mcp only requires mcp:read. A read-only token could therefore mint a pre-authorized write into the host's storage. The tool now checks the scope itself. A whitespace-only filename also sanitized down to an empty string, producing a key ending in '/' — a folder marker on S3-style backends — and a File whose name was empty. It is trimmed at the schema and falls back to 'file'. The mime type error said 'required' for a value that was supplied but malformed, sending the model down the wrong path. And CLAUDE.md claimed the tool goes through the activity log, which it does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing authenticates the PUT endpoint and the body was buffered to the end with no limit, so an arbitrarily large request could run the example agent out of memory. It now refuses as soon as 25 MiB is crossed — above the 20 MiB fileUploads default, so an oversized upload is still reported by the server's own maxBytes check rather than masked here. Verified with a 30 MB body: 413, nothing written, the process survives and the heap stays flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prefix check was not enough. A data uri with no comma made Buffer.from raise a TypeError, and a lone '%' in a media type made decodeURIComponent raise a URIError — both surface as a generic 500, so a model that sent a malformed value never learned what to send instead. Both now produce the same ValidationError as a value that is not a data uri at all. A properly percent-encoded name still decodes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UploadStorage contract puts no bound on a read, so a backend that stopped answering held its concurrency slot indefinitely. The caller's own request timeout fires meanwhile — a real client cuts at 30s — so the failure was invisible here while the slots drained away one by one, and every later redemption queued behind them forever. Reads are now bounded by downloadTimeoutSeconds, 15s by default, which leaves half of a 30s caller budget for encoding and the action itself. The waiting queue is capped too: past that point the last waiter would be served around the time the caller has given up anyway, so failing tells the model to retry instead of holding it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36d743d to
49d688a
Compare
| claims: verifyUploadHandle(handle, userId, uploads.authSecret), | ||
| })); | ||
|
|
||
| const files = new Map( |
There was a problem hiding this comment.
🟠 High file-uploads/resolve.ts:145
Promise.all(verified.map(...)) submits every file reference to the bounded semaphore simultaneously. The semaphore only allows limit active plus 2 * limit queued tasks, so any action with more than 3 * maxConcurrentDownloads distinct file references is deterministically rejected with "Too many uploads" — even when the server has no other load. Retrying the same action cannot succeed. Consider feeding the downloads through the concurrency limit incrementally instead of enqueuing them all at once, or otherwise exempt a single accepted action from the overload queue cap.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/mcp-server/src/file-uploads/resolve.ts around line 145:
`Promise.all(verified.map(...))` submits every file reference to the bounded semaphore simultaneously. The semaphore only allows `limit` active plus `2 * limit` queued tasks, so any action with more than `3 * maxConcurrentDownloads` distinct file references is deterministically rejected with "Too many uploads" — even when the server has no other load. Retrying the same action cannot succeed. Consider feeding the downloads through the concurrency limit incrementally instead of enqueuing them all at once, or otherwise exempt a single accepted action from the overload queue cap.
There was a problem hiding this comment.
Correct, and this was a regression I introduced two commits earlier: the queue cap was meant to shed cross-request overload, but one action submits all its references at once, so it also capped a single action at 3 × maxConcurrentDownloads references — 15 by default, deterministically, with retrying unable to help.
Fixed in 914548c by removing the cap rather than exempting one action. The cap was defending against a hanging backend holding slots forever, and downloadTimeoutSeconds (added in the previous commit) already bounds that — so it was a second mechanism for a problem already solved, whose only distinct effect was rejecting valid work. A test now asserts a burst of 25 references is served with a limit of 2.
A storage backend is an object with methods, so unlike every other standalone option it cannot travel through an environment variable. File uploads were therefore reachable only by embedding the server in an agent or by writing a custom entry point — which left the standalone CLI, the way this is actually deployed, unable to turn the feature on at all. FOREST_MCP_UPLOAD_STORAGE_MODULE points at a module default-exporting the options, or a function returning them. A missing path or a module without a storage fails at startup, like the other options, rather than running with uploads silently disabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing had to be provisioned to use them, yet they still had to be switched on — so every agent shipped with File-field actions that could not run over MCP until someone found the option. `fileUploads` now only configures the feature: a storage backend, size limits, ttls. `enabledTools` is the off switch, and it takes everything with it — leaving `requestActionFileUpload` out skips the tool, the upload endpoint and the `executeAction` instructions together, so the server never advertises a tool it did not register. The single-instance warning moves from startup to the first createUploadUrl. Announcing at boot would now reach every agent, including those whose actions have no file field, which is noise rather than a warning. Note what this widens: every agent that mounts the MCP server now serves an unauthenticated PUT endpoint on its own origin. It only accepts keys this server issued, each single-use and expiring, and holds at most ephemeralMaxTotalBytes.
| // refuse a replacement that fits, and keeping them until 'end' would hold both at once. | ||
| this.forget(key); | ||
|
|
||
| if (this.storedBytes + this.inFlightBytes >= maxTotalBytes) { |
There was a problem hiding this comment.
🟠 High file-uploads/ephemeral-storage.ts:165
A zero-byte upload receives HTTP 507 when storedBytes + inFlightBytes exactly equals maxTotalBytes, even though it requires no additional capacity and fits within the limit. Use a strict greater-than check so empty uploads are admitted while non-empty uploads are rejected as they exceed the limit.
| if (this.storedBytes + this.inFlightBytes >= maxTotalBytes) { | |
| if (this.storedBytes + this.inFlightBytes > maxTotalBytes) { |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/mcp-server/src/file-uploads/ephemeral-storage.ts around line 165:
A zero-byte upload receives HTTP `507` when `storedBytes + inFlightBytes` exactly equals `maxTotalBytes`, even though it requires no additional capacity and fits within the limit. Use a strict greater-than check so empty uploads are admitted while non-empty uploads are rejected as they exceed the limit.
There was a problem hiding this comment.
Not taken, deliberately — with one correction to what the code claims.
You are right that a zero-byte upload needs no capacity and that >= refuses it. But > moves the common case from a 507 answered before a byte is read to a 413 decided on the first chunk, and 507 is the more accurate signal for "this server is out of room" — it is the one status that tells an operator the store is the problem rather than the file. Trading that for an empty file that arrives exactly when the store is full is not a trade I would make; an empty upload on its own is already accepted and tested.
What was wrong is the comment, which read as if the check were about the declared body size. It now says it refuses when the store has no room at all, whatever the body.
The prerequisite sent the model to "Settings > Capabilities > Code execution and file creation > Additional allowed domains". That panel does not exist in every Claude Desktop build — on a managed workspace the code execution capability runs while its network setting is not exposed to the user at all, so the text pointed at a menu they cannot open. It now names the requirement — the host must be publicly reachable and allowed for outbound traffic in that environment — and says the setting may be an administrator one. The README tells integrators to give their users the host to get allowed rather than a menu path.
Named as a motivating case for the storage backend, and it is no longer one: Forest Cloud is being wound down. The warning still names serverless runtimes and multiple replicas, which is what remains — and both are the deployments a self-hosted agent chooses deliberately rather than inherits.
Normalizing `['String']` to `'StringList'` inside `getType()` changed what two API responses publish, not just a type: agent-bff (read by the frontend) and workflow-executor (read by the workflow editor) both put `field.getType()` straight into their form-field payload. The normalization existed only to satisfy their declared `getType(): string`, which was the part that was wrong — the runtime had always emitted the array for list fields. So `getType()` returns the wire value again and `getTypeName()` carries the collapsed name, used where a name is what is wanted: dispatching to the file encoder, and the type this server reports to a model — `getActionForm` has to say `FileList`, since the upload tool's own description tells the model to look for a field of that type. Both consumers' declared types now say what they always emitted.
…module From review, in order of what a user would hit. **A retry after any post-download failure lost every file.** `download()` deleted the object on read, while `resolveUploadedFileValues` fetches every reference *before* `setFields` and `execute` — the sequence the tool's own description tells the model to retry. A mistyped field name or a throwing hook therefore left the second attempt reporting that uploads had failed when they had succeeded. Nothing is consumed on read now; `expire()` and `ephemeralMaxTotalBytes` reclaim instead. The upload url stays single-use, which is a different property, enforced on the issued key. **A change hook on an action with a File field returned a 500.** On the `getActionForm` path the handle reached the hook, which read `.buffer` off a string. File references are withheld from `tryToSetFields` there, so the field reads as unset — what it was before the model chose a destination. **A storage module that exports a broken storage booted as if configured** and silently ran the in-memory store, putting a deliberately replicated deployment on the one backend that cannot serve it. The three methods are duck-typed when the key is present, naming the missing one; omitting `storage` entirely stays the documented way to ask for the in-memory store. **A data uri without `;base64` decoded to garbage.** `Buffer.from(x, 'base64')` skips what it cannot read rather than throwing, so `data:text/plain,hello` became 3 bytes of nonsense and reached the end user as a corrupt file, reported as a success. Smaller, same review: `getSize` failures are wrapped like `download`'s (a missing object is the likeliest failure of the flow, and it was reaching the model as a bare SDK string); an unusable handle names its field instead of `jwt expired`; the upload 404 admits another instance may have issued the key; `maxBytes` above `ephemeralMaxTotalBytes` warns instead of advertising a size every upload of which is refused; accented filenames survive sanitizing; building a second app no longer swaps the store under the first one's urls; and the semaphore's unreachable `Math.max(1, …)` is gone, with the test that only covered it. The README's claims about consume-on-read, change hooks and what `maxConcurrentDownloads` bounds after a timeout are corrected rather than left describing the old behaviour.
…ay reject Two comments that described the code less precisely than the review of them did: the total-bytes check reads as being about the declared body size when it is about the store having no room at all, and the `getSize` contract said what to return when a size is unavailable but nothing about an absent object — which is the likeliest outcome of the whole flow.
Changed to 'StringList' earlier in this PR to match a getType() that has since been reverted, so it went back to failing. `['String']` is what it asserted from the day the package landed — the oldest evidence in the repo that the agent emits the array, and the reason the wire had to stay as it was.
Tested from a Claude Desktop chat against an agent behind a public HTTPS URL, with that host added to the sandbox's allowed domains: the model's PUT goes through. The same sandbox had answered `Host not in allowlist` for an ordinary public domain beforehand, so the allowlist is the whole of the second condition and satisfying it is enough. Still no menu path in either the tool description or here. Where the setting lives differs between clients and versions, and naming one sends a blocked user to a panel that may not have it — which is exactly what happened while testing this.
…y off Withholding the handle from the change hooks fixed a 500 but made the field unfillable: the agent never saw it, so `getValue()` stayed undefined, the field stayed in `requiredFields`, and `canExecute` could never become true. The tool's own description tells the model to call `getActionForm` until it does — with no value that gets there, since a data uri is what it is told never to send and the handle was being dropped. The handle is now echoed back as that field's value and counted as filling it, while still never reaching the agent. `fileUploads: false` turns the whole feature off. `enabledTools` could do it, but it is an allowlist: declining one experimental feature that way meant naming the ten other tools and opting out of everything shipped later — a poor trade for something on by default. It drops the tool from the enabled set, so registration, the upload endpoint and the executeAction paragraph all follow. Also states the store's real capacity: an object survives redemption now, so 64 MiB of defaults holds about three max-size files per 45-minute window rather than a rolling 64 MiB.
Threat-modeling the leaked-url case per backend showed the two defenses do not live where they seemed to. The in-memory store is single-use, so a leaked url cannot replace bytes that already landed. A presigned backend url is the opposite: S3 accepts as many PUTs as fit in expiresInSeconds, so on the production backend a url leaked to an access log can overwrite the upload after it happened, until the action runs. The sha256 pin is the one defense that covers both — S3 signs it into the url so a different payload is rejected at upload, and redemption re-verifies it regardless. Requiring the signed handle on the PUT instead would not even be implementable there: a presigned request carries its signature in the query, and S3 rejects a request presenting a second authorization mechanism. So the tool now tells the model to compute and pass the digest as the normal course, skipping it only when it cannot, and the README states the replayable window an unpinned upload accepts.
Three agents over the last unreviewed 121 lines. What they caught: - `in` on the withheld map walked the prototype chain, so a required field literally named toString read as filled by Object.prototype.toString and canExecute came back true on an empty form. Own-property check instead. - fileUploads: false silently deleted a tool the caller had explicitly listed in enabledTools — in a resolver whose convention is to log every enablement surprise. It still wins, and now says so. - The standalone server had no way to express fileUploads: false, while the README discouraged the one route it had. FOREST_MCP_FILE_UPLOADS returns with the only meaning left to it: 'false' turns the feature off, wins over a configured storage module, and any other value than true/false fails at startup instead of silently leaving the feature on. - Two comments this PR itself falsified still named enabledTools as "the one way" to turn uploads off (server.ts and CLAUDE.md); the tool description claimed url-leak substitution unconditionally when the in-memory store's single-use url makes it impossible after the upload; the README said the single-instance warning fires at startup when it fires on first use; and the S3 example never said unhoistableHeaders is what keeps the checksum enforced. New tests: the FileList half of the withheld-handle behavior (array echoed back, required field counted as filled), the prototype-chain case, and the fileUploads-false-vs-enabledTools precedence with its warning.
| maxTotalBytes: this.fileUploads.ephemeralMaxTotalBytes, | ||
| ttlSeconds: this.fileUploads.handleTtlSeconds, | ||
| issuedTtlSeconds: this.fileUploads.uploadUrlTtlSeconds, | ||
| publicBaseUrl: new URL(uploadsPath, effectiveBaseUrl).href, |
There was a problem hiding this comment.
🟠 High src/server.ts:606
When effectiveBaseUrl includes a pathname such as /agent/, generated in-memory upload URLs point to /mcp/uploads, while the router is mounted at /agent/mcp/uploads, so uploads consistently fail with 404/405. new URL(uploadsPath, effectiveBaseUrl) treats the leading slash as an origin-absolute path; resolve mcp/uploads relative to mountBase instead, as for mcpResourceUrl.
- publicBaseUrl: new URL(uploadsPath, effectiveBaseUrl).href,
+ publicBaseUrl: new URL('mcp/uploads', mountBase).href,🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/mcp-server/src/server.ts around line 606:
When `effectiveBaseUrl` includes a pathname such as `/agent/`, generated in-memory upload URLs point to `/mcp/uploads`, while the router is mounted at `/agent/mcp/uploads`, so uploads consistently fail with 404/405. `new URL(uploadsPath, effectiveBaseUrl)` treats the leading slash as an origin-absolute path; resolve `mcp/uploads` relative to `mountBase` instead, as for `mcpResourceUrl`.
| **Verified end to end** from a Claude Desktop chat: an agent behind a public HTTPS URL, that host | ||
| added to the sandbox's allowed domains, and the model's `PUT` goes through. Before the host was | ||
| allowed, the same sandbox answered `Host not in allowlist` even for an ordinary public domain — | ||
| so the allowlist is the whole of condition 2, and satisfying it is enough. |
There was a problem hiding this comment.
Claude Opus 5 (claude-opus-5): Docs only — nothing to fix here. Two results from a manual pass, both for this section and the limitations table in the PR body.
Cowork works, end to end
The PR body still says "Does not work as tested: the session runs remotely, so even the MCP transport cannot reach a local agent. Untested against a public agent." That has now been tested against a public agent, and it works.
Setup was an agent behind a public HTTPS URL with that host in the sandbox's egress allowlist — the same two conditions this section already states for Claude Desktop. Verified from the server side rather than from the model's account of it:
14:33:40 getActionForm → discovers the File field
14:33:44 requestActionFileUpload {filename, mimeType, sha256}
14:33:51 PUT 15095 bytes from 160.79.106.138 → 200 → a cloud IP, not the dev machine
14:33:57 getActionForm {Document: "$uploadedFile:…"} → canExecute true
14:34:03 executeAction {Document: "$uploadedFile:…"}
No base64 in any tool argument — checked against every POST /mcp body captured at the tunnel, not the transcript. The pinned digest matched the source file byte for byte and the server re-verified it on download.
So the original verdict was an artifact of testing against a localhost agent, which is condition 1 of this very section. Worth folding Cowork into the same bullet as Claude Desktop rather than leaving it as a separate failure row — same topology, same two conditions, same outcome.
The affordance holds without a scripted prompt
That run came from one sentence — "Can you attach this invoice to review 1 in Forest please?" — with no tool named, no instruction against inlining, and no mention of a digest. The model still found getActionForm → File field → requestActionFileUpload, and pinned the sha256 unprompted. That chain only exists in prose in the tool descriptions, so it is worth knowing it survives a realistic operator prompt and not just a test script.
One wrinkle for the docs
The filename the action stored was rapport1815.pdf; the file on disk was rapport-1815.pdf. Not sanitizeFilename — it preserves hyphens, and the same run kept the parentheses and accents in rapport final;v2 (été).pdf. So either the client normalised the attachment's filename in the sandbox or the model retyped it. Bytes and mime type were correct.
Harmless, but it means the name a customer's action persists can differ from the name the user recognises, and that is invisible from this side. Probably one sentence here: the filename is whatever the client reports, so treat it as a label rather than an identifier.
There was a problem hiding this comment.
Folded in, d6913ab — thank you for closing this with server-side evidence rather than the transcript.
- The README's client section now carries Cowork in the same bullet as Desktop and Claude.ai (same topology, same two conditions), with the verification described: single natural sentence, sha256 pinned unprompted, no base64 in any
POST /mcpbody. The PR body's compatibility row says the same and names the earlier verdict for what it was — an artifact of testing againstlocalhost. - The filename wrinkle is documented where the conditions are: a sandbox may normalize the attachment's name before the model sees it, so the name a customer's action stores is a label, not an identifier. Your hyphen case is the example.
The unprompted-affordance observation is the most valuable line of the three for the release notes: the whole chain lives only in tool-description prose, and it survived a realistic operator prompt.
Cowork's "does not work" verdict was an artifact of testing against a localhost agent — condition 1 of this very section. Against a public agent with the host allowed it completes end to end, from a single natural sentence, with the sha256 pinned unprompted and no base64 in any tool argument, verified against the request bodies at the tunnel rather than the transcript. Also observed there: a sandbox may normalize the attachment's filename before the model ever sees it, so the name a customer's action stores can differ from the name the user recognises. Stated as: a label, not an identifier.
The reference page gains the requestActionFileUpload tool and a section on action file uploads: the flow that keeps bytes out of the model's context, the in-memory default and its single-instance limit, the storage backend, both off switches, the client prerequisites with the two admin-owned settings on managed workspaces, and the integrity model including the sha256 pin and the observed filename normalization. Ships with ForestAdmin/agent-nodejs#1815.
# @forestadmin/datasource-toolkit [1.55.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-toolkit@1.54.0...@forestadmin/datasource-toolkit@1.55.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c))
# @forestadmin/plugin-aws-s3 [1.6.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.5.14...@forestadmin/plugin-aws-s3@1.6.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c)) ### Dependencies * **@forestadmin/datasource-toolkit:** upgraded to 1.55.0 * **@forestadmin/datasource-customizer:** upgraded to 1.70.1
# @forestadmin/agent-client [1.14.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.13.1...@forestadmin/agent-client@1.14.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c)) ### Dependencies * **@forestadmin/datasource-toolkit:** upgraded to 1.55.0 * **@forestadmin/forestadmin-client:** upgraded to 1.42.1
# @forestadmin/mcp-server [1.22.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.21.1...@forestadmin/mcp-server@1.22.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c)) ### Dependencies * **@forestadmin/agent-client:** upgraded to 1.14.0 * **@forestadmin/forestadmin-client:** upgraded to 1.42.1
# @forestadmin/workflow-executor [1.24.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/workflow-executor@1.23.9...@forestadmin/workflow-executor@1.24.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c)) ### Dependencies * **@forestadmin/agent-client:** upgraded to 1.14.0 * **@forestadmin/ai-proxy:** upgraded to 1.12.6 * **@forestadmin/forestadmin-client:** upgraded to 1.42.1
# @forestadmin/agent-bff [1.18.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-bff@1.17.0...@forestadmin/agent-bff@1.18.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c)) ### Dependencies * **@forestadmin/agent-client:** upgraded to 1.14.0 * **@forestadmin/datasource-toolkit:** upgraded to 1.55.0 * **@forestadmin/forestadmin-client:** upgraded to 1.42.1
# @forestadmin/agent [1.95.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.94.1...@forestadmin/agent@1.95.0) (2026-08-17) ### Features * upload files through the MCP server for action File fields ([#1815](#1815)) ([f09e88c](f09e88c)) ### Dependencies * **@forestadmin/datasource-customizer:** upgraded to 1.70.1 * **@forestadmin/datasource-toolkit:** upgraded to 1.55.0 * **@forestadmin/forestadmin-client:** upgraded to 1.42.1 * **@forestadmin/mcp-server:** upgraded to 1.22.0 * **@forestadmin/datasource-sql:** upgraded to 1.17.12 * **@forestadmin/workflow-executor:** upgraded to 1.24.0

Action forms with File fields could not run over MCP. The agent expects a file value as a data uri, so the bytes had to travel through the model's context window — a 2-5 MB document costs hundreds of thousands of tokens and exceeds most MCP clients' payload limits. Two customers hit this: Roundtable in April (document-extraction flow blocked on the upload step) and Qonto, who opened #1814 to implement it themselves.
This PR builds on that contribution. Reviewing it surfaced a deeper gap, so the work splits in three.
fixes PRD-913
1. agent-client owns the encoding
setFieldswas a pass-through with no support forFile/['File']fields, so every caller had to hand-craft the data uri. It now accepts aFileobject ({ buffer, mimeType, name }) and encodes it; strings pass through untouched, so already-encoded data uris and opaque references still work.The codec itself was duplicated in the agent and in plugin-aws-s3 (and the
Filetype redeclared), so it moves todatasource-toolkitand both reuse it — -90 lines. Running the agent's and the plugin's existing suites against the shared implementation is what proves encode/decode symmetry.Three fixes come along for free:
nameis now percent-encoded. feat(mcp-server): enable action file fields via an upload side-channel #1814 built the data uri by hand without it, so a filename likerapport final;v2.pdfbroke the agent's header parsing (it splits on;).parseDataUrifails readably. No comma raised aTypeError, a lone%aURIError, and both surfaced as a generic 500 — from a value a model populates freely. They are now aValidationErrorcarrying the message, which the agent renders as a 400. Onlynameandcharsetare read back from the media types, so a uri carryingbuffer=oopscan no longer replace the decoded bytes with a string.makeDataUrino longer emitscharset=undefined.No agent change is needed, on any generation. agent-client hits the standard
/forest/...routes, so what we send is byte-for-byte what the browser already sends for a File field.getType()now answers'FileList'where it used to leak the wire array['File']. The array form is kept in the payload echoed back to the agent, whose change-hook parser matches only that shape. This is what keepsgetType(): stringand unbreaksagent-bffandworkflow-executor.2. The MCP upload side-channel
The
requestActionFileUploadtool returns a pre-authorized upload URL plus a handle bound to the requesting user; the client uploads straight to the storage;executeActionswaps"$uploadedFile:<handle>"for the downloaded file. The model only ever exchanges the small handle.It is a tool, not the HTTP route #1814 proposed: a route is invisible to clients, the only thing telling a model it existed was a sentence in the
executeActiondescription. A tool is listed bytools/listwith its schema, and the call goes through the same logging as the others. It is registered only whenfileUploadsis set, and it checks themcp:actionscope itself —/mcponly requiresmcp:read, and the route it replaced requiredmcp:action, so moving to a tool would otherwise have dropped an authorization check.Nothing to provision.
fileUploads: {}is enough: the server holds the objects in memory and serves its own PUT endpoint under<origin>/mcp/uploads. Provide astoragefor anything beyond a single instance — the upload and the redemption are two separate requests, so with several replicas or on a serverless runtime one lands where the other did not. That is announced at startup and named again in the failure, rather than surfacing as a flaky feature.It is not free, and not forced either way. #1814 required a
storage, so the upload alwayswent to a bucket — one stable domain, shared by every customer on S3. The in-memory default sends
it to the customer's own agent origin instead: a different domain per customer, and one that no
client pre-allows. So this removes the provisioning friction and moves it onto the allowlist a
hosted sandbox enforces. Deliberate: a developer trying the feature with Claude Code needs no cloud
account, and anyone deploying to a walled client can still set a
storagewhose domain is easierto get allowed. Requiring a bucket to make one client's allowlist simpler would tax everyone else.
Changes from #1814, all from review:
resolve.tsreturns aFileobject instead of building the data uri — that is what removes the percent-encoding bug above.getSizeis required onUploadStorage. It was optional, and without itdownload()buffers the whole object before themaxBytescheck, so the README's memory bound was not true.downloadTimeoutSeconds(15 s). Unbounded, a backend that stopped answering held its concurrency slot while the caller's own timeout fired — a real client cuts at 30 s — so the slots drained away invisibly.maxConcurrentDownloads: 0used to queue every redemption with nothing left to release it, hanging instead of failing.parseFileReferenceis isolated as the single place recognizing a reference, so SEP-2631 file URIs can be added without touching the resolution path.@experimental, and the standalone binary can enable it (FOREST_MCP_FILE_UPLOADS, orFOREST_MCP_UPLOAD_STORAGE_MODULEfor a real backend — a storage is an object, so it cannot travel through an env var).Kept from the original: the stateless JWT handle, the user binding, the optional sha256 pin re-verified on download, the concurrency bound, and the deliberate choice not to resolve handles in
getActionForm(it echoes values back into the model's context).3. A runnable example
packages/_exampleenables it withfileUploads: {}— no storage code, no cloud account. Itsreviewcollection carries anAttach a documentaction with aFileand aFileListfield, reporting the name, mime type and byte count it received.Verification
Tested end to end against a real agent over MCP, not only in unit tests:
Received rapport final_v2.pdf (application/pdf, 51 bytes)— the exact source sizeFileListStringfield{"buffer":{"type":"Buffer",...}}into the columnCross-language contract verified by execution, not by reading:
agent-ruby's ownForestValueConverter.parse_data_uridecodes what this encoder produces, names with;,=space and%included, 50/50 bytes. And the bug it fixes is real there too — un-encoded,rapport final;v2.pdfarrived as"rapport final", anda,b=c.txtraisedArgumentError: invalid base64.~5 700 tests green across the touched packages, 0 lint errors, 100% coverage on
file-uploads/.Known limitations
Client compatibility, tested against the running
_example:localhostagent — the only client that can be tried without deploying anything.mcp-remoteruns locally. ThePUTleaves a hosted sandbox whose egress allowlist was measured: before the host was allowed,PUT https://httpbin.org/putfrom there answeredHost not in allowlistand plainhttps://example.comcame back 403. With the agent behind a public HTTPS URL and that host added to the allowed domains, the upload goes through. Never works againstlocalhost.localhost. Verified server-side: the run started from one natural sentence, the model pinned the sha256 unprompted, and noPOST /mcpbody carried base64.Both conditions are client-side and outside this server's control. What the server can do, it
does: the tool states the prerequisite in its description and repeats it in its response, so a
model whose upload was blocked reports that rather than guessing at an expired handle.
The allowlist entry is the whole of it. Satisfying it is enough — no other client-side
condition surfaced. Where that setting lives is deliberately not documented: it differs between
clients and versions, and naming a path sends a blocked user to a panel that may not have it,
which is what happened while testing this.
The product consequence is a support path, not a bug. A user whose upload is blocked has to
ask an administrator of their own Anthropic organization to allow the Forest agent's domain —
someone with no reason to have heard of Forest Admin — once per agent domain. There is no
single-domain shortcut to offer them: serving the bytes from one Forest-owned host would mean
Forest infrastructure holding customer files, which is precisely what a self-hosted agent exists
to avoid.
So Claude Desktop chat is not a channel this feature can carry on its own. Claude Code and
programmatic callers, including the integration this started from, are unaffected — and being
developer-side, they are also where action file uploads are actually driven from.
The in-memory store is single-instance and lost on restart. Multiple replicas, cluster mode and serverless runtimes need a
storage.A File field that declares a change hook receives the unresolved handle in that hook.
workflow-executorgains the plumbing but has no source of bytes, so this does not yet let it send files.agent-pythonwas not verified (not checked out locally).agent-phpis archived.Co-authored with @stefanoamorelli, whose #1814 this supersedes.
Note
Add file upload support to the MCP server via a new
requestActionFileUploadtoolrequestActionFileUploadMCP tool that issues a pre-signed upload URL and a JWT-bound file handle for use in action form fields; enforcesmcp:actionscope and input validation (mime type, sha256 digest, filename sanitization)./mcp/uploadsHTTP endpoint backed by in-memoryEphemeralStoragewhen no external storage backend is provided; supports per-file and total store size limits, single-use upload URLs, and TTL-based expiry.executeActionreceives file handle values, it now resolves them to in-memoryFileobjects with concurrency limiting, per-file size enforcement, optional digest verification, and timeout handling before invoking the action.FOREST_MCP_UPLOAD_STORAGE_MODULEenvironment variable to load a customUploadStoragebackend (e.g. S3) at startup; the module can export options directly or as a sync/async factory.@forestadmin/datasource-toolkitand updatesagent,agent-client, andplugin-aws-s3to use these shared utilities; file-type field values that are not data URIs are now passed through unchanged rather than parsed.EphemeralStorageis single-process and in-memory only — data is lost on restart and is unsuitable for multi-instance deployments; a warning is logged on first use.Changes since #1815 opened
ActionFormFieldtype property andActionField.getType()return type fromstringtostring | [string]acrossagent-bff,agent-client, andworkflow-executorpackages to preserve list types as arrays in their wire format, and addedActionField.getTypeName()andFieldGetter.getTypeName()methods that return collapsed type names converting array forms like['File']to'FileList'[a961a95]EphemeralStorage.download()method inmcp-serverto no longer delete stored objects after retrieval, allowing repeated downloads of the same key until expiry or memory reclamation [a961a95]declareGetActionFormToolhandler inmcp-serverto filter out file reference placeholders from provided values before callingtryToSetFields, preventing file upload handles from being posted to change hooks [a961a95]parseDataUri()function indatasource-toolkitto reject data URIs that do not includebase64encoding in their media type, throwing aValidationErrorinstead of decoding to invalid data [a961a95]mcp-serverfile upload resolution to provide field-scoped diagnostic messages whengetSize()fails or upload handles are invalid, instructing operators to check the upload step or request new uploads [a961a95]loadFileUploads()function inmcp-serverto verify custom storage modules export required methods (createUploadUrl,download,getSize) and reject misconfigured modules early instead of falling back to in-memory storage [a961a95]ForestMCPServer.buildExpressApp()method inmcp-serverto prevent reinitialization of file upload storage on router rebuilds and log a warning whenmaxBytesexceedsephemeralMaxTotalBytesfor in-memory store [a961a95]sanitizeFilename()function inrequest-action-file-uploadtool to use Unicode-aware regex pattern with\p{L}and\p{N}for preserving letters and numbers across all locales while sanitizing other characters to underscores [a961a95]createSemaphore()function inmcp-serverthat previously coerced limits below 1 to 1, now using the provided limit value as-is [a961a95]@forestadmin/agent-testingpackage to expectField.getType()to return['String']instead of'StringList'for a list field [f720ae5]mcp-serverpackage README to confirm end-to-end verification of sandbox outbound allowlist requirement, added concrete example demonstrating that PUT requests succeed when agent host is added to sandbox allowed domains and fail with 'Host not in allowlist' error when not allowlisted, and restructured content to emphasize documenting upload host configuration [cee8109]getActionFormtool handler to withhold file handle values fromtryToSetFieldswhile treating them as satisfying required file fields [2190fd5]fileUploads: falseto bothAgentandForestMCPServer[2190fd5]fileUploads: falseoption [2190fd5]mcp-serverpackage [b6f30be]FOREST_MCP_FILE_UPLOADSenvironment variable to control action file upload functionality [cb63a04]requestActionFileUploadtool description [cb63a04]Macroscope summarized bb62c80.