Every AI tool you use can take a file. The protocol built to connect those tools to your actual systems cannot, and the reason is not the one you would expect.
I have worked on a few projects now with an MCP component in them, and the same thing has caught me out every time. Going in, you expect the hard parts to be authentication, per-user permissions, and stopping an agent from doing something irreversible on a Friday afternoon. Those are hard, but they can all be solved. Letting someone attach a file looks like the easy bit. Every other piece of software ever written has managed it.
It turns out to be the one part the protocol has no answer for. I have built the same workaround three times now, and somewhere in the third it dawned on me that I was hand-building a missing piece of the protocol.
Attach a PDF in a chat window and it works. Point a coding agent at a directory and it reads the files. The major model APIs all have a files endpoint: upload once, get an identifier back, refer to it by that handle for as long as you need. Desktop assistants take drag-and-drop. At the product layer this problem is thoroughly and boringly solved.
The Model Context Protocol is the layer that connects those products to real systems: your database, your CMS, your order pipeline. Its whole job is to carry a request from a model to a service that does something. And it has no way to carry a file.
Not "handles it badly". There is no file input type at all. A tool call is a JSON-RPC message, and its inputs are described by a JSON Schema: strings, numbers, booleans, objects, arrays. That is the entire vocabulary.
The obvious workaround is to base64-encode the bytes into a string argument. Most teams try this first, mine included. It works beautifully in testing and falls apart the moment a real user shows up. The usual explanation is that base64 adds 33% overhead and blows the context window. That is true, but it describes the wrong problem.
The real problem is about who does the typing. A tool call involves three actors, not one. The model picks the tool and produces its arguments. The client, meaning the application hosting the model, whether that is a desktop assistant, an IDE or a coding agent, assembles the JSON-RPC request and sends it. The server receives it. In most conversational hosts today, tool arguments start life as model output, generated token by token the same way a sentence is. So putting file bytes in an argument means something has to type the whole file out, perfectly. By default that something is the model.
| Input | base64 length | cl100k_base | o200k_base |
|---|---|---|---|
| A real 126 KB PNG | 168,436 chars | 111,875 tokens | 106,284 tokens |
| A 512,000-byte surrogate | 682,668 chars | 489,461 tokens | 466,097 tokens |
Both numbers are measured. The surrogate can be reproduced exactly from the method note at the end, and the PNG is a real file, identified there by its hash. Look at the first row. An ordinary 126 KB screenshot, the kind of thing anyone drags into a chat window without thinking, costs about 112,000 output tokens to send as a tool argument.
Base64 lands between 1.4 and 1.53 characters per token, and closer to 1.4 on cl100k_base. The PNG in the table was a kind one. See the addendum. Ordinary English prose runs at roughly four, which is the rule of thumb most people reach for, and it misleads badly here. Byte-pair encoders are trained on natural language, and base64 gives them very little to work with: mixed case, digits, no word boundaries, almost none of the structure a merge table exists to exploit. It does still earn some merges, since 1.4 characters per token sits well above the one-per-character floor. Just nowhere near enough to help. I only measured these two OpenAI tokenizers. Other vendors use different vocabularies, and in some cases different schemes entirely, so treat the counts as a guide rather than a universal figure. The size of the gap is the point, not the decimal place.
Scale that up to a 500 KiB photo and you get close to half a million output tokens for a single image. That is several times the per-response output limit of most current models. Under those limits the request does not just get expensive. It cannot finish at all, not as arguments the model has to generate. And if it somehow could, it would cost more than every other message in the conversation put together and take minutes to produce.
This is a hard limit, not a performance problem. Bigger context windows do not fix it. Faster inference does not fix it. The model is being asked to be a file transfer protocol.
The client could step in, though. Nothing in the specification stops a host from checking, rewriting or adding arguments before it sends them, and a host that let you pick a file and attached the bytes itself would skip the model entirely. That is how attachments already work in a chat window. The application reads the file and puts it into the model's input, or hands over a reference to storage it already controls. The model receives the file. It never types it.
So here is the actual gap: there is no standard way to ask a client to do that. No schema keyword marks an input as a file, so no host knows to offer a file picker, and no server can count on one that does. That leaves two options. Either the model types the bytes, or every host invents a private mechanism no server can rely on. This is a missing standard rather than a law of physics, which is why it can be fixed, and why someone has proposed a fix.
What makes this sting is that MCP already moves binary data, just in one direction. Open the SDK and look at what a tool is allowed to return.
A rich vocabulary. A tool can hand back an image, an audio clip, an arbitrary binary blob.
One channel, JSON only. No file type exists in it, and by default the model produces every value in it.
Binary comes down in five typed content blocks, three of which carry bytes. Going the other way there is no file type at all, only strings, and somebody has to generate those. A protocol built to let models act on real systems assumed that acting on a system never means handing it a file. That describes almost no business system anyone has ever built.
The gap is real and the work still has to get done, so a folklore of workarounds has grown up around it. They are not equally good. The order matters here: each one survives a little further into real use than the one above it.
Base64 in the argument: toy files only Works up to a few kilobytes. Fails on every real photo, spreadsheet or export. Worse, it fails quietly: the model hands back something truncated or subtly corrupt instead of refusing.
A local filesystem path: dies when remote
Pass /Users/me/products.csv and let the server open it. This is fine when the server runs on the same machine over stdio, and it is why so many MCP servers are local-only. Host that server, containerise it, or make it multi-tenant, and it no longer shares a disk with the user. The whole approach falls apart.
An agent-supplied URL the server fetches: you just built an SSRF Have the model pass a link and let the server download it. Now a model-controlled string decides what your server sends requests to, from inside your network. Doing that safely needs allowlisting, redirect handling and private-range blocking, and most implementations have none of it. Some providers avoid the pattern completely, for a simple reason: fetching a model-supplied URL safely takes a lot of defensive code, and not fetching is cheaper than defending.
An out-of-band upload ticket: correct, unspecified The tool returns a short-lived, single-use URL. The client sends the bytes there over ordinary HTTPS, outside the protocol. Only a small reference travels through MCP. This is the one that holds up, and it is the one you have to build yourself.
A pre-uploaded file handle: correct, needs a host The file already sits in storage the platform controls, so the model just passes an identifier. Excellent when you have that storage and the user has already put files in it. Not a general answer, though, because it assumes the upload happened somewhere else first.
The ticket, every time. The flow is simple once you see it. The tool gets called with no file and replies with a status of awaiting_upload and a URL. Whoever holds the bytes, the client or the agent itself, POSTs them to that URL and gets back a job identifier to poll. The gateway streams the file straight through to the existing backend API, which does not have to change at all. It stores nothing.
The ticket is nothing exotic. It is a long random string that names a short-lived record kept on the server. That record holds whatever the server will need when the bytes turn up: who called the tool, where the file is going, and when the permit expires. Only the string goes out to the agent, inside the upload URL. Everything else stays put.
Four things have to be true of it, because that string is a bearer credential sitting on an endpoint with nothing else guarding it. It needs enough randomness that nobody can guess it. It needs a short expiry. Redemption has to be atomic, because a read followed by a delete is a race rather than a guarantee. And if you run more than one instance, all of them need to see it, since the upload rarely lands on the replica that issued the permit.
Where you keep it is a real choice, and the options trade off differently.
A shared cache with native TTL
Redis, Valkey, Memcached. Expiry comes for free and every replica sees the same record. A single GETDEL makes redemption atomic in one round trip. That is what I built, and it is the one choice here I would change. Destroying the record on redemption is exactly what rules out the completion states described further down. Flipping a status field instead, using a transaction, a script or a CAS operation depending on the store, is just as atomic and keeps the record around to reason about.
I went with the shared cache for a boring reason that will apply to plenty of other people. Redis was already a core part of the stack, so it added no new infrastructure. It gives you expiry, cross-replica visibility and atomic redemption directly. You still generate the randomness yourself, and the completion behaviour described below is application logic whichever store you pick. If there had been no cache, I would have used a database row. The ticket is a small idea and should not justify a new dependency.
One thing about this looks like a security hole and is not. The upload endpoint asks for no session, no bearer header and no OAuth, because the ticket is the authentication. It covers one file, one destination, one use, which is deliberately much narrower than the session that created it. Reusing the caller's normal token would be worse either way. If the model is driving the upload it cannot supply one anyway. If the client is driving, sending a broad long-lived credential to a second endpoint on another origin just widens the damage when it leaks. This is why the draft calls its method files/authorizeUpload. It is an authorization step, not a URL generator.
Every one of these cost me real debugging time, and none of them were predictable from the spec.
"Is this a form?" is not "is this a file upload?"
Framework helpers that check for form content, like ASP.NET's HasFormContentType, also return true for URL-encoded bodies. Requests that could never contain a file sail past the guard meant to stop them, burning a single-use ticket on the way through. Check for multipart specifically, not for "is a form".
I built this three times, twice for data files moving in and out and once for images, before I noticed it was one thing wearing three hats. That is the tell. When a pattern keeps getting reinvented, in one codebase and then the next, it is not a feature of your application. It is a missing layer underneath it.
It is not just me, either. Someone tested file uploads across seven well-known MCP services and found that none of them move files through the protocol. The ones that look like they work rely on filesystem access that breaks as soon as the server is remote. They all converge on the same pattern: move the bytes over a side channel, pass a small reference through MCP, and keep the payload out of the model's context entirely.
A workaround that implementers keep arriving at independently is not a workaround. It is an unwritten specification.
The encouraging part is that the protocol's own contributors are reaching the same conclusion, by much the same route that implementers keep taking on their own.
2026-04-22: SEP-2631, File Objects and Transfer draft
Opens. Proposes files/authorizeUpload and files/authorizeDownload: negotiated out-of-band HTTPS transfer, with a FileValue carrying a URI plus name, MIME type, size and digest. Large payloads leave JSON-RPC. Small ones may still travel inline as data: URIs, with a transferModes parameter letting a server say which it prefers.
Look at that sequence. The first instinct was to carry files inline. What replaced it keeps inline transfer for the small cases and adds negotiated out-of-band transfer for everything else. That is the journey from workaround #1 to workaround #4 above, and the same journey I made from base64 to tickets. The draft and the field got to the same architecture separately, which is usually a sign the architecture is right.
The draft had its own open questions, and they sit at the level of protocol surface. Whether files/authorizeUpload must support both raw-body and multipart uploads in v1, or let servers advertise just one. Whether a generated file should have a single normative result shape or two. Whether a client-owned artifact store belongs in future work.
The questions below are a different set. Not the draft's, but the ones that turn up in the first two weeks of running any of this for real, and that you will answer with or without a specification. Anyone who has already shipped the workaround has an answer worth comparing.
Build the ticket. It is the architecture the draft is converging on anyway. The authorization and transfer path itself is small, and most of the work is in the hardening around it: lifecycle state, retries and idempotency, size ceilings and timeouts, log hygiene, and the cross-replica coordination that only shows up once you have more than one instance.
I did. mcp-upload is the ticket as a Python library: pip install "mcp-upload[mcp]", declare where uploads may go, attach it to a server built on the official SDK or FastMCP, and return what it describes. Atomic single-use redemption, streaming pass-through with nothing on disk, a browser page on the ticket URL so it works from a chat window too, and the two-round elicitation flow the July protocol replaced server-initiated requests with. Source and tests are on GitHub. The addendum below is what building it taught me that this piece got wrong or left out.
Be realistic about how far it reaches, too. It works today wherever whoever holds the file can also make an HTTP request, which means coding agents and CLI tools. A chat or desktop host that cannot get at the user's file bytes, or has no route from a tool-returned URL to a file picker, gets nothing from it, and those users are still uploading by hand. Closing that gap is exactly what the spec work is for.
Keep the vocabulary clean while you do it. Name things the way the proposal names them: an authorization step, a file value, a URI with metadata. Then when the spec settles on a file transfer method, adopting it means re-skinning an interface rather than redesigning a feature. I treat the ticket service as a private implementation of a public method that does not exist yet.
The wider lesson will keep coming back as this protocol matures. A standard that connects language models to real systems has to keep dealing with the fact that models produce tokens, while the systems they connect to run on bytes. Files are just the first place that gap got too big to paper over.
A week after publishing this I built the ticket as a library, mcp-upload, and had every claim above checked against the live spec, the SDK and a lot of measurement. The architecture held. Some of the numbers and one of the confident sentences did not, and a few things only showed up once there was code. Here they are. One disclosure first: the library and this addendum were written with AI assistance (Claude Code), under my direction and review. The wire shapes follow SEP-2631 by Casey Chow, and the pattern itself was reached independently by several implementers before either of us wrote it down.
Sample B is a real file and the count is right for that file. It is not typical. Across ten other real PNGs between 20 KB and 320 KB, base64 tokenized at 1.40 to 1.46 characters per token on cl100k_base and 1.46 to 1.53 on o200k_base. Sample B sits at 1.51 and 1.58, above every one of them. A typical 126 KB PNG costs about 115,000 to 121,000 output tokens, not 112,000. The error runs in the argument's favor, and the top of the "1.4 to 1.6" range was never reached. Random bytes reproduce the surrogate row to within 0.03%, and compressed image data is close enough to random that they are a fair stand-in.
Two things worth saying precisely. A 126 KB screenshot at about 120,000 tokens fits inside the 128,000-token per-response ceiling of the largest models available today, with about five percent to spare, and does not fit on a model with a 64k ceiling, such as Claude Haiku 4.5. The 500 KiB photo does not fit on any of them. And these are OpenAI tokenizer counts. Anthropic publishes no offline tokenizer, only a counting endpoint, so I have not measured Claude's cost for the same file. Nothing about base64 suggests it would be kinder.
The official Python SDK's Streamable HTTP server rejects any request body over 4 MiB with HTTP 413 before it parses the JSON. A 3 MiB photo as base64 fails there regardless of who produced the bytes. I argued above from what a model can generate. The stronger argument is that the reference implementation refuses the request anyway.
I wrote that the client or the agent posts the file. Checked against the hosts as they exist today, that holds unaided in exactly one place: a coding agent with a shell, which runs curl -F. Claude.ai, Claude Desktop, ChatGPT, Cursor and VS Code document no path from a tool-returned URL to a file on disk, and none of them act on an upload request by themselves. So the library renders a form on a GET of the ticket URL. Opening a link is the one thing every host and every person can do, and it turns the URL in the transcript from a dead end into a working affordance.
In SEP-2631 the client asks for an upload authorization, uploads, and then calls the tool with a file URI. The tool call comes last. The flow above, tool first and upload second, is what the proposal calls its fallback for when the client cannot bind the file before the call. Today that is every host. So the fallback is the only path anyone can complete, and I would argue it deserves the same care in the text as the default. Mirroring the data shapes still pays off. The record carries a stable mcp-file:// URI from the start, and a files/authorizeUpload adapter on top is a thin addition rather than a redesign.
Since the 2026-07-28 revision a server cannot open a request to the client in the middle of a call. There is no channel for it. Call the SDK's URL elicitation helper from inside a tool and it raises. What replaced it is a two-round call: the tool returns input_required naming what it needs, the client collects it and retries the same call with the answer attached, and the tool body runs again from the top. That last part matters for tickets. The retry must not mint a second one, so the record id rides in the opaque state the client echoes back, and the second round looks the record up instead of issuing. Built that way, the client shows the upload link and asks for consent, and the tool reports what happened.
The natural way to accept a multipart upload in Python, await request.form(), does not blow up memory. It writes the whole file to a temporary file on disk before your handler runs, and puts no limit on file parts. That is not streaming. Real pass-through took an incremental parser with async callbacks, a bounded queue between the parser and the outgoing request, and a backend request that drains the queue. When the backend is slow the queue fills, the parser stops, and the client's upload stalls. Measured over real sockets, 32 MiB through a deliberately slow backend grew the process by about 5 MiB.
I framed atomic redemption above as a multi-instance concern. It is worse than that. With an async store interface the natural implementation reads the record, checks it, awaits something, and writes it back. Fifty concurrent redemptions of one ticket against that code all win, in a single process, with a plain dictionary. The fix is a synchronous check and write with no await between them, or a lock held across both. That test is the one I would lead with now, because it is the one every reviewer of a forty-line version will fail.
The destination can never come from a tool argument. Letting a model name the URL a server fetches is a request forgery. Letting it name the URL a server streams a file into is the same hole with a body attached, so the library has no API that accepts a URL and tools pick a destination by a name the server author registered. And the end of the upstream body should be held until the whole incoming request has parsed, so that if something objectionable follows the file part, the backend sees an incomplete request rather than a committed upload.
After I posted the measurements in the spec repository, bruchris pointed out a distinction this piece never draws, and it is worth drawing. Plenty of upload APIs already hand their own client a short-lived URL and let the bytes go straight to storage. Canvas works that way, so does S3 presigned POST, and structurally it is the same ticket. It sits at a different hop.
So there are two separate claims. One is that your backend does not push file bytes through your application server. The other is that your MCP tool does not push them through the model. A tool can wrap an API that satisfies the first and satisfy none of the second, because its own arguments still carry base64 and the model still has to type the file. The cost measured above lands identically on a modern upload API and a naive one. What changes it is the direction the ticket travels: outward, past the tool boundary, to whoever actually holds the file.
Worth being plain about which one the library does. It does the second and not the first. The bytes still cross the server, on purpose, because that is what lets it enforce the size limit on what actually arrives and record whether the upload finished.
References: SEP-2631, File Objects and Transfer · SEP-2356, closed · The 2026-07-28 specification · Discussion #1197, passing files from the client · Seven MCP services tested
Method: token counts measured with tiktoken 0.14.0 against cl100k_base and o200k_base, encoding each sample with base64.b64encode and taking len(enc.encode(…)).
Sample A, fully reproducible: 512,000 bytes from random.Random(20260828).randbytes(512000), standing in for compressed image data. sha256 of source 6e1fc89391a5e6ae4641a92d3f9c44f3b892ff5fce529c8336b3840bf36447f4; of the base64 c522b46b00f6183467db35cf837e871f35add8a802a30c671a680e3cd94c70ed.
Sample B, a real 126,325-byte PNG, identified rather than reproducible: sha256 of source 0a1f16f6e9d7db77f3e356ac5c12a9c8e173625841a0e6f6fb9da1ee0eb2cee0; of the base64 26a9b4564f3583c1cdf2a32cc0a50958712b801e349319db7eb1ace2bcce8e99.
Only these two tokenizer families were measured. The prose comparison is the customary four-characters-per-token approximation, not a measurement of a named corpus. Implementation details have been generalized; failure modes and sequencing decisions are as encountered.