SEP-2631: File Objects and Transfer - #2631
Conversation
9f7caad to
d1e6de6
Compare
Casey is the author of SEP-2631 (File Objects and Transfer), see modelcontextprotocol/modelcontextprotocol#2631. :house: Remote-Dev: homespace
d1e6de6 to
8c45ad6
Compare
fe09240 to
9bb59fa
Compare
9bb59fa to
27b0a95
Compare
89d390c to
c3cf0eb
Compare
| "files": { | ||
| "upload": true, | ||
| "download": true, | ||
| "transports": ["https"] |
There was a problem hiding this comment.
A thing I would worry about and price into the concern here early on is max file size / support for chunked file uploads. Many HTTP server implementations themselves enforce upload size constraints at the transport layer and it'd be helpful to communicate this back to the MCP client.
| For generated files and file content blocks, this SEP introduces `FileValue`: file URI | ||
| plus optional display and integrity metadata. | ||
|
|
||
| ```json |
|
|
||
| interface AuthorizedFile { | ||
| file: FileValue; | ||
| download?: FileTransferDescriptor; |
There was a problem hiding this comment.
this pattern is great, very similar to what I have to do with the existing MCP limitations today for one of our services for doing email attachments: https://textforge.net/docs/mcp#tools-attachment-management - return a pre-authorized URL for the upload itself.
|
|
||
| interface AuthorizedFile { | ||
| file: FileValue; | ||
| download?: FileTransferDescriptor; |
There was a problem hiding this comment.
In this vein, one thing I would include here is an expiration date on the file transfer descriptor. In a stateful MCP session this wouldn't be an issue - the file transfer descriptors die with the session. But in the upcoming stateless world of MCP, if these descriptors can theoretically live forever that's a potential security vector if they get unused.
For instance,
- Claude Code session prompts to upload a file;
- User gets prompted for approval, but never does;
- Session closes, file is never uploaded;
- If descriptor is still open, could this user re-use it at any arbitrary date in the future?
Scoping the lifetime of these to some bounded window of time would eliminate the need to persist these descriptors on the serve and eliminate this problem too.
(I apologize if you've already addressed this elsewhere - I'm, reading the document from top to bottom)
|
|
||
| The server responds with an upload authorization: | ||
|
|
||
| ```json |
| Implementations **SHOULD** use standard JSON-RPC errors with the following guidance: | ||
|
|
||
| - `-32601` when `files/authorizeUpload` or `files/authorizeDownload` is not supported. | ||
| - `-32602` when a file URI is malformed or violates declared constraints such as |
There was a problem hiding this comment.
I would include another error code in here that implementors will want to have on the server: rejecting a file that doesn't pass some type of security validation. i.e. if I do magic byte detection on a file that claims it's a .png but it isn't, or ClamAV rejects a .zip file due to its contents, that should be distinguished from a user-error or a server failure.
| base64. Other servers may prefer inline `data:` URIs for small, lightweight files where | ||
| an upload negotiation round trip would be unnecessary. | ||
|
|
||
| `transferModes` keeps that policy with the file input declaration. Omitting it preserves |
There was a problem hiding this comment.
Probably a subject for another SEP, but I would consider introducing a "chunked" mode for larger files. A use case for this I have personally run into is automated video editing via MCPs. This aligns with what I wrote earlier about the server advertising the max acceptable size and maybe having MCP clients chunk in order to meet the requirements.
A distinction I'd make here:
- Max total file size == that part of the spec is totally clear right now, no issues.
- Max per-upload size == this is a mechanical issue with the server itself, not a business constraint the server is enforcing on clients. That's what I was suggesting be advertised in my earlier comment, albeit less clearly than I am trying to do now. That'd be a necessary ingredient for supporting chunking in the future.
There was a problem hiding this comment.
👍
Chunked mode would also require marking it as complete and how garbage collection is accomplished.
|
|
||
| ### Why File Values for Outputs | ||
|
|
||
| Generated files often need display metadata in addition to a URI. A `FileValue` gives the |
There was a problem hiding this comment.
this is a good abstraction and a good design choice.
| Implementations **SHOULD** use standard JSON-RPC errors with the following guidance: | ||
|
|
||
| - `-32601` when `files/authorizeUpload` or `files/authorizeDownload` is not supported. | ||
| - `-32602` when a file URI is malformed or violates declared constraints such as |
There was a problem hiding this comment.
Beyond maxSize, probably need errors for expired handles and uploads which would violate quotas (i.e. file counts, total file size).
| - Servers **MUST** validate MIME type, size, and file content according to their own | ||
| policy rather than trusting client metadata. |
There was a problem hiding this comment.
- Servers should also apply limits to the total counts and sizes of files.
- Servers should also apply limits to the number of pending authorized uploads/downloads
| Servers **MUST** treat file arguments as URI strings whose file URI values are resolved | ||
| through the upload contract, not as local filesystem paths or storage-specific handles. |
There was a problem hiding this comment.
Since the upload is out-of-band, when the server compares digests is left open and thus how the error is surfaced should be covered in this section.
| The main performance impact is positive: | ||
|
|
||
| - large files no longer need to be encoded into inline `data:` URIs inside JSON-RPC | ||
| messages; | ||
| - intermediaries and servers avoid parsing oversized request bodies for file content; | ||
| - clients can use storage-native upload and download paths. | ||
|
|
||
| This SEP does add one control-plane round trip for upload or download negotiation, but | ||
| that tradeoff is preferable to making every file transfer inline. |
There was a problem hiding this comment.
One negative would be abandoned uploads from clients, especially if they're added as part of stateless sessions. I realize this is probably diving a bit into the black box of "implementation details" for each server, but either (a) standard error codes, or (b) standards for expressing retention would help with client-side reasoning without enforcing an implementation.
| "name": "report.pdf", | ||
| "mimeType": "application/pdf", | ||
| "size": 248123, | ||
| "digest": { | ||
| "algorithm": "sha-256", | ||
| "value": "uU0nuZNNPgilLlLX2n2r-sSE7-N6U4D6ZVe-_rYh2sU" | ||
| } |
There was a problem hiding this comment.
This looks good, however there is a gap in these params. In many signed upload system I know, the signature covers the upload policy, not just the file. Take S3 presigned POST as the most familiar example. The server signs a policy document like this:
{
"expiration": "2026-08-17T12:00:00Z",
"conditions": [
{"bucket": "media-assets"},
["starts-with", "$key", "uploads/customer-42/"],
["starts-with", "$Content-Type", "image/"],
["content-length-range", 0, 26214400]
]
}
The destination path, allowed content type and size limits are baked into the signature, and the upload endpoint rejects anything that does not match. The client cannot change them because that is the whole point of signing.
ImageKit/Cloudinary works the same way, production flow is literally this SEP's shape already: the client gets a signed JWT from API and multipart POSTs the file with that token, and the JWT signs the destination folder, tags and overwrite behavior.
To produce that signature the server must know those values at signing time. In this SEP, signing time is files/authorizeUpload, but the destination folder and similar values are ordinary tool arguments that only arrive later in tools/call, after the bytes have already been uploaded. At authorize time the server only sees name, mimeType, size and digest.
Nothing in the schema forbids extra params on this request, but that does not help in practice. The client builds this request and will only send what the spec defines. There is no way for a server to declare that authorizing an upload for a given file input needs certain values from the pending tool call, so no client will ever send them. Note that in the tool call flow the client already holds the full arguments object at this point, with only the file field waiting for substitution, so the information exists on the client side and is simply not forwarded.
Without this, providers like us (ImageKit) have to sign for a staging location, accept the upload there, and move the file once tools/call arrives with the real destination. That means an extra copy, a window where the file is in the wrong place, and cleanup for staged files whose tool call never arrives.
Suggestion: let the file input declaration name the tool arguments that the client must include in the files/authorizeUpload params, similar in spirit to how x-mcp-file already marks the field itself. Happy to share details of our production flow if useful.
| Client->>Upload: POST multipart/form-data { fileField: "file", file: report.pdf, token } | ||
| Upload-->>Client: 200 OK | ||
| Client->>Server: tools/call analyze_document { document: "mcp-file://server/file_01HXYZ" } | ||
| Server-->>Client: CallToolResult content or structuredContent |
There was a problem hiding this comment.
Looking at this sequence, the upload endpoint's response terminates at the client on line 520 and nothing from it ever reaches the server. The next message the server sees is tools/call carrying only the file URI it minted earlier.
That is a problem because upload endpoints usually return a meaningful body: the stored object's final identifier, its location, size, sometimes a content hash. The server issued the file URI before any bytes existed, and the final identity of the stored object is often assigned by the upload service at upload time, so the server cannot predict it at authorize time. After the upload, the only party holding the ground truth about what was actually stored is the client, and the protocol gives it no way to hand that back.
The server is left to discover the outcome of an upload it authorized itself, through side channels: deterministic staging keys plus a move step, callbacks from the storage service, or proxying the bytes through the server, which defeats the point of out of band transfer. All of that is overhead to recover information the client already has in hand.
Suggestion: add a completion step between the upload and tools/call, for example a files/completeUpload message from the client with the file URI, the HTTP status, and optionally the upload endpoint's response body. The server should treat a relayed body as a claim to verify rather than trust, but verifying a claim is much cheaper than discovering the outcome from scratch. An explicit completion message would also resolve two things already discussed in this review: it gives the server a definitive signal for the race where tools/call arrives before the upload finished, and a natural expiry point for authorizations that were never used.
I would keep this out of the tools/call arguments themselves, since those are model produced and schema validated; a control plane message between client and server is the right layer.
| Clients that support out-of-band file transfer declare a new `files` capability during | ||
| initialization: | ||
|
|
There was a problem hiding this comment.
Minor update needed since stateless landed recently, moving this to _meta. Maybe something like:
{
"jsonrpc": "2.0",
"id": 10,
"method": "files/authorizeUpload",
"params": {
"name": "report.pdf",
"mimeType": "application/pdf",
"size": 248123,
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/files": {
"upload": true,
"download": true,
"transports": ["https"]
}
}
}
}
}
}
| This SEP supports elicitation by allowing the eventual value for an `x-mcp-file` field to | ||
| be either a SEP-2356 `data:` URI or, when out-of-band transfer is used, a file URI | ||
| prepared through `files/authorizeUpload`. It does not add a new elicitation mode or change | ||
| the surrounding elicitation control flow. |
There was a problem hiding this comment.
Also minor update needed for stateless since elicitation was deprecated and move to the resultType: "input_required" pattern. It's a pretty clean map in the new stateless. model though server asks for a file with the "input_required" call, client calls files/authorizeUpload, uploads out of band, retries with the file URI in inputResponses.
| This SEP extends [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) | ||
| with out-of-band file transfer and generated file outputs. SEP-2356 defines | ||
| `x-mcp-file`, an inline JSON Schema extension keyword for declaring file-valued fields, | ||
| and uses URI strings, including RFC 2397 `data:` URIs, as the baseline input value while |
There was a problem hiding this comment.
Parts of 2356 this spec needs probably need to be rolled into this SEP, since 2356 is closed in favor of 2631 ( #2356 (comment) )
Looks like the definitions of. x-mcp-file, accept, maxSize, and transferModes would cover most of it.
| ```json | ||
| { | ||
| "jsonrpc": "2.0", | ||
| "id": 10, | ||
| "method": "files/authorizeUpload", | ||
| "params": { | ||
| "name": "report.pdf", | ||
| "mimeType": "application/pdf", | ||
| "size": 248123, | ||
| "digest": { | ||
| "algorithm": "sha-256", | ||
| "value": "uU0nuZNNPgilLlLX2n2r-sSE7-N6U4D6ZVe-_rYh2sU" | ||
| } | ||
| } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
I think it would be valuable to support binding of the file upload to an intended purpose - something like:
{
"target": {
"method": "tools/call",
"name": "analyze_document",
"argumentPath": "/document"
},
"file": {
"name": "report.pdf",
"mimeType": "application/pdf",
"size": 248123
}
}
for example, when uploading the file I'd like to say "hey, this file is authorized for "analyze_document" and not "publish_to_x". With codemode and other capabilities, as well as the inherent nondeterminism of agents I think this kind of defense would be great. I could likely be made optional, but I think there's real value in putting it out up front.
|
Hey, so I was pushing a lot for MCP file transfer in #2356 but it got closed and referred to here. When can I expect this to work? I need it urgently for my business. @ochafik @localden @nickcoai are you guys still on it? https://modelcontextprotocol.io/community/working-groups/file-uploads this page says the target was May and June, what is going on here? |

Summary
This PR introduces SEP-2631 for first-class MCP file transfer. It builds on SEP-2356's
x-mcp-fileinput declaration model, keeps file-valued inputs as URI strings, and adds out-of-band upload/download negotiation plus generated file outputs.Motivation
MCP has partial primitives for file-like workflows, but no single interoperable story for "the user gives a file to a tool" or "a tool returns a file to the user." Implementations currently rely on ad hoc argument conventions, hosted-storage handles, inline base64, or harness-specific rewriting. That makes file upload behavior drift across clients, gateways, and servers, and it makes it hard for server authors to declare portable file-taking tools.
What This SEP Proposes
This SEP adds a focused file transfer contract:
x-mcp-fileon URI string schema properties.data:URIs or with file URIs obtained throughfiles/authorizeUpload.x-mcp-file.transferModeslets servers allow inline transfer, require upload transfer, or leave clients free to choose.FileValueoutputs — generated or returned files use a URI plus optional display metadata such as name, MIME type, and size.files/authorizeUploadandfiles/authorizeDownloadauthorize HTTPS transfer descriptors while keeping bytes out of JSON-RPC payloads.resources/read.FileValuecan appear in structured tool output, file content blocks, and optional file-backed resource contents.Relationship to Other SEPs
This SEP is designed to complement nearby work:
x-mcp-filedeclarations and extends the URI-string value contract with negotiated file URI transfer.Validation
npm run generateenv npm_config_cache=/private/tmp/mcp-npm-cache mise x node@22.20.0 -- npm run checkAI Assistance Disclosure
This SEP was drafted with Codex assistance under human direction and review.