DEV Community

Kishan Soni
Kishan Soni

Posted on

I added an MCP server to my image host — here's everything that bit me

A while back I wrote about why I built x02 — I needed somewhere to host images for my own projects and reuse them across sites, and over time I added only what I needed.

It's still that. But the biggest change this year is that you can now connect x02 to Claude, ChatGPT or Grok and just ask for your files:

"Find the screenshot I uploaded last Tuesday and tell me what's in it."

That's an MCP server. Building it taught me more about the protocol than the spec did, mostly by breaking. Here's what I got wrong, in case it saves you the same afternoons.


First I deleted the CLI

Before any of this, I had a CLI. It logged you in with an API key. Except the endpoint it called — GET /api/auth/user/:apiKey — didn't exist.

My SPA catch-all returns 200 with an HTML page for anything unmatched. So axios never threw. The CLI parsed the HTML, found no error, and accepted any string as a valid key.

It had shipped like that. Nobody noticed, because nobody used it.

I deleted it instead of fixing it. The lesson I'd actually pass on: a catch-all that returns 200 makes every missing endpoint look alive, and any client that treats "no exception" as "success" will believe it.


The MCP tools call my own public API, not my database

The obvious design is to have MCP tools talk to the data layer directly. I didn't.

Every upload on x02 goes through quota checks, ban checks, NSFW scanning, filename allocation and audit logging — all of which live in the upload route, not the DAL. Calling the DAL from MCP would mean reimplementing all of it, and then watching the two copies drift apart the first time I changed one.

So the MCP tools make HTTP calls to x02's own public API over loopback. Slightly silly on paper. Zero duplicated business logic, which is the only thing I actually cared about.


destructiveHint defaults to true

Tool annotations tell the client whether a tool reads, writes, or destroys. I set destructiveHint: true on my two delete tools and left it off everywhere else, assuming absence meant "not destructive."

It doesn't. The MCP spec defaults it to true. My upload tool, my folder-create tool and six others were all advertising themselves as dangerous, and clients were prompting users harder than they needed to.

Silence is not neutral. I ended up making the TypeScript type require an explicit value so it can't be skipped.


openWorldHint is not "does this make a network call"

Then I set openWorldHint: true on everything, reasoning that every tool reaches a remote service.

Wrong axis. The hint describes how wide a tool's domain of interaction is — open-ended external entities versus a bounded space. All my tools operate on one x02 account, which is closed. Only "rehost this image from a URL" dereferences an address that could point anywhere.

Fourteen of fifteen tools were overstating their reach. OpenAI's review process flags exactly this, and lists incorrect action labels as a leading cause of rejection.


An outputSchema silently ate every image

This is the one I'd most want to have known in advance.

I have a view_file tool that returns a downscaled thumbnail as an MCP image content block, so the assistant can actually look at your picture instead of guessing from the filename. I also declared an outputSchema with the thumbnail's dimensions, because more structure seemed better.

It worked perfectly in Claude. Then someone tried it in ChatGPT:

"the X02 tool returned only its thumbnail metadata rather than the actual image pixels"

The spec says a tool declaring an outputSchema must return structuredContent — and clients that read structured output are free to ignore the unstructured content blocks entirely. Which is where the image lives.

Claude reads both. ChatGPT reads the structured object and drops the rest. So the image was being sent and thrown away, and every test I had still passed.

Removing the outputSchema fixed it. The dimensions moved into the caption text. If your tool's whole purpose is to return an image, audio or a file, don't declare an output schema — you're telling the client the JSON is the real answer.

Test it in two clients. One client is not a test.


You cannot send a file from a chat to a server

There is no client-to-server file transfer primitive in MCP. There's an open proposal for one, unimplemented. No client exposes chat attachments as fetchable URLs either, and none should.

So the only way to upload is base64 inside the tool call. A perfectly ordinary 150 KB screenshot becomes roughly 200,000 characters, and most clients simply refuse to emit that. "Upload this image" — the single most obvious thing to ask a file host — did not work.

What I ended up with: the assistant calls request_file_from_user, which returns a short link. You open it, paste or drop the file, and it uploads through the same endpoint the website uses — so quota, scanning and folders all apply, with nothing reimplemented.

The part I like is the waiting. The assistant's tool call blocks on an in-memory EventEmitter keyed by the drop code, with a database re-check on entry so a file that already landed returns instantly. It resolves the moment the upload finishes, and the assistant continues mid-answer. You never have to type "done."

There's also a standing tray: drop files at /drop beforehand, then ask, and they're already waiting.


ChatGPT gives you two tools unless you flip a flag

Added as an ordinary connector, ChatGPT exposes exactly two tools from any MCP server: search and fetch. Nothing else. My server has neither.

The connection succeeds. It looks completely fine. Then every request fails with "isn't exposed to me in the current toolset" and ChatGPT quietly falls back to searching the web.

The fix is Developer mode, under Settings → Apps & Connectors → Advanced. It's one level deeper than most guides suggest, which is why people report not finding it.

Worth knowing before you spend an afternoon assuming your server is broken. I did.


Where it ended up

Fifteen tools — browse, search, view images, upload, rehost from a URL, rename, move, folders, share links, delete. OAuth 2.1 with PKCE and dynamic client registration, so you sign in on x02.me and approve scopes instead of pasting an API key into someone else's software. An app that only asked to read your files can't delete them, and disconnecting one app doesn't touch anything else.

It's live in Anthropic's connector directory. The ChatGPT listing is in review.

If you want to try it, the whole configuration is one address:

https://up.x02.me/mcp
Enter fullscreen mode Exit fullscreen mode

Setup guide for each app · x02.me

As always, happy to answer questions, talk through any of the design decisions, and I appreciate the feedback.

Top comments (0)