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.6 characters per token. 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 four typed forms. 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 has 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.
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 SEP 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 files/authorizeUpload is ratified, 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.
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.