DEV Community

Cover image for Zero of 7 Services Support File Upload Through MCP. Here's What Actually Works.
Ken Imoto
Ken Imoto

Posted on Originally published at zenn.dev

Zero of 7 Services Support File Upload Through MCP. Here's What Actually Works.

The receipt attachment that started it

I was wiring MCP into an accounting workflow. The whole flow was fine until the last step, which was the reason for the workflow in the first place: attach the receipt PDF to the transaction. The agent produced a beautiful, well-formed tool call. The server said, essentially, "no."

I assumed it was a bug in one MCP server. So I tried a different one. Same shape of failure. Different message.

That was the moment I stopped blaming the vendor and started reading the MCP spec.

The 7-service test

I picked seven services where "upload a file" is not a nice-to-have. It's the whole point some days.

  • freee (accounting) — receipts, invoices
  • Jira / Confluence — screenshots, logs on issues
  • Notion — images, PDFs on pages
  • GitHub — screenshots on PRs
  • Gmail — attachments on outgoing mail
  • Google Drive — obviously
  • Slack — files in channels

Each one has an official or well-known MCP server. Each one's underlying HTTP API supports file uploads. The question I actually cared about was narrower: can an MCP client, going through the MCP protocol, get a file into that service?

The tally at the end:

  • ✅ Full protocol support: 0
  • △ Works, but by leaving the protocol: 4
  • ❌ Flat rejection: 3

Zero complete implementations across seven services. That's the story.

A comparison card headed MCP File Upload, 7-service test. The headline reads full protocol support zero of seven. Three grouped rows: freee, Jira and GitHub marked flat reject; Gmail, Drive and Slack marked local path; Notion marked presigned URL. A final tally reads zero of seven in-protocol

Why: MCP doesn't have a FileContent type

The reason is one paragraph in the spec, not a bug in any one server.

An MCP tool result can carry TextContent, ImageContent (base64), AudioContent, ResourceLink, and EmbeddedResource. There is no FileContent. Anything that isn't text, a base64 image, or a base64 audio blob is off the protocol's happy path. EmbeddedResource lets you point at a URI, which sounds like a workaround until you notice it moves the problem — the client and server still have to agree on how to actually move the bytes, and MCP has nothing to say about that.

The clearest confirmation is from an Anthropic maintainer on GitHub Discussion #1197, replying to someone hitting exactly this wall: "I don't think you're overlooking anything, your use-case is currently finicky in the current state of the protocol."

That is the whole thing. The protocol is finicky here on purpose. It's not a gap; it's a shape choice.

The proposed fix, and what happened to it

There was a live thread on this. SEP-1306 ("Binary Mode Elicitation for File Uploads") was opened in August 2025 and is now closed — though the "draft" label is still on it, which makes it look alive if you only glance at search results. The idea: extend the elicitation feature with a third mode so a server can ask the user for a file, keeping the same user-consent model that already exists for text and confirmation.

The status, as of the mid-2026 spec cycle, is roughly:

  • SEP-1306 sat as a proposal through early 2026.
  • The July 2026 release candidate deferred binary transfer entirely (there was a follow-up SEP-2631 draft that also got pushed).
  • Work has moved to a File Uploads Working Group, anchored on SEP-2356, which is where the design conversation lives now.

Read: there is a plan. There is no shipped protocol answer yet. If you have a customer waiting on receipts today, you're not going to be helped by the pending spec.

What each of the three flat-rejects actually says

Not paraphrased — this is what maintainers and docs say when you push on it.

freee. The underlying API accepts multipart/form-data. MCP's JSON-RPC transport does not carry multipart. So the tool exists in the API and does not exist through MCP. If your compliance regime requires the receipt image on file, this is the one that stings the most.

Jira / Confluence. Atlassian's community answer is direct: "file uploads or image attachments via the MCP Remote Agent are not supported." Worse, the community mcp-atlassian server needs the file to sit on the MCP server's filesystem before it can attach — which explodes the moment the server is in Docker and the file is on the client's disk. Issue #618 has the exact stack trace: "File not found: /home/user/jira-mcp/grafana.png".

GitHub. Requested in github-mcp-server Issue #738: "the MCP needs to be able to upload images and at the moment that doesn't seem to be possible. By doing this we'll be able to have more descriptive / visual PRs." The obvious workflow — "here's a screenshot of the UI regression" — doesn't exist.

The four that work by leaving the protocol

Gmail. Some community Gmail MCP servers accept a local file path as a tool argument, then the server reads the file itself and attaches it. The bytes never traversed MCP. If the MCP server isn't on the same filesystem as the file, you're back to square one.

Google Drive. Same shape. The server reads from a local path and calls the Drive API. Handy on your laptop. Fragile the moment "the server" is somewhere else.

Slack. The CData Slack MCP has an UploadFile tool that behaves this way. Slack's own MCP focuses on messages and search — no upload primitive there.

The pattern in those three: the MCP server is a proxy sitting on top of a filesystem the client already trusts it with. It works, and there is a specific reason it works, but calling it "MCP file upload support" is generous. It's "MCP text call that triggers a filesystem read."

Notion. This one is a different shape, and a better one. Notion's MCP exposes notion-create-file-upload: the tool hands back a short-lived upload URL along with the headers and form field the client has to use, and the client sends the bytes itself. Up to 20 MiB. Look at what that is — it's the presigned-URL pattern I land on at the bottom of this article, already shipped. The bytes still never travel through MCP, which is why it sits in this group and not in the first one. But nobody had to wait for the spec.

Why the protocol was built this way

Three reasons, all defensible even if they're inconvenient.

JSON-RPC first. MCP is a JSON/text protocol. Binary transport is out of scope by design. Adding it isn't a line change; it's a shape change.

Security. The moment you let arbitrary paths cross the tool boundary, path-based command injection, exfiltration by "please read this file," and malware smuggling all become easier. The Jira workaround above shows exactly why the "just accept a path" model has security teeth marks in it.

Context cost. Base64 encoding inflates payload roughly 33%. A 1 MB image becomes ~1.33 MB of text and — worse — of tokens on every turn the model can still see it. Even if you shipped it as ImageContent, you'd be spending context you don't get back.

None of these make the receipt attach itself. They do make the "why is this hard" answer coherent instead of embarrassing.

What actually works right now

Since the protocol won't move bytes, stop asking it to. Three patterns hold up in production, roughly ordered from cleanest to most tolerated.

1. Presigned URL, upload out of band. The MCP tool call returns a short-lived upload URL (S3 or the vendor's equivalent). The client PUTs the file to that URL directly, entirely outside MCP, then hands the resulting object reference back into the next MCP call. This is what SEP-1306's uploadEndpoints was pointing at as the sanctioned shape. You can implement it today; you don't need the spec to bless it.

// Tool 1: get a place to put the bytes
const { uploadUrl, fileRef } = await mcp.call("prepare_upload", {
  filename: "receipt-2026-08.pdf",
  size: fileSize,
});

// Bytes move over plain HTTPS, not MCP
await fetch(uploadUrl, { method: "PUT", body: fileBuffer });

// Tool 2: reference the uploaded object
await mcp.call("attach_receipt", { transactionId, fileRef });
Enter fullscreen mode Exit fullscreen mode

2. Shared object storage as the medium. Client and server both authenticate against the same bucket. The client uploads; the MCP call carries only the object key. Fine when the client and server are in the same trust zone. Ugly when they aren't.

3. Base64 through ImageContent, and only for images. Genuinely useful for small screenshots. Falls apart above about 500 KB, both because of token cost and because you'll hit request-size limits. Never do this for PDFs. Just don't.

The one I'd avoid: shoving a raw filesystem path across the boundary and hoping. It "works" until the server moves to a container and then it doesn't, silently.

The one-line version

MCP has no file-upload primitive, and the fix has been deferred through at least one major spec cycle. Until it lands, treat MCP as the coordinator and let the bytes move over an out-of-band channel you already trust. Every "MCP file upload works!" tutorial you'll read is either doing that under the hood or is about to bite the person who copies it.

If you want the full walk — the 7-service teardown with the exact error messages, the SEP-1306 timeline, the OWASP MCP Top 10 threat model, and the production-ready presigned-URL server template — I wrote it up in MCP Security in Practice.

Top comments (0)