We Built an MCP Server for Image Generation
We shipped a Model Context Protocol server so an AI agent can design and render images with Imejis. The renderer already existed. The interesting work was the layer that lets an agent drive it well, and not fall out of sync the first time we shipped a new component. Here's what we learned building it.
Generate the agents world from one source of truthGenerate the agent's world from one source of truth
The failure mode for "our AI can use your tool" is drift. You describe the tool to the model once, the tool changes, the description doesn't, and the agent starts confidently calling things that no longer exist. Every stale doc is a lie the model believes.
We didn't want a second copy of anything. So the catalog an agent reads over MCP, the list of components, their properties, allowed values, and defaults, is generated at request time from the component registry. That registry is the same source of truth the renderer uses to validate a design. There is exactly one definition of "a chart component," and both the renderer and the agent read it.
Ship a new component, and the agent knows about it on the next call. Nobody updates a doc. The docs are the registry.
Guard the boundary with a test not disciplineGuard the boundary with a test, not discipline
One wrinkle made this harder than it sounds. Our API runs on a newer major of our schema library than the renderer's registry does, and you can't share schema objects across a major version. So the MCP layer keeps a small validation mirror: a hand-written schema it uses to sanity-check a design before sending it on.
A hand-written mirror is exactly the kind of thing that drifts. So we don't rely on remembering to update it. A drift test takes every component's sample design straight from the registry and runs it through the mirror, on every build. If the registry grows a property the mirror doesn't know about, the test goes red. The mirror can lag reality for about as long as it takes CI to run.
The lesson generalizes past our schema quirk: when you must keep two representations of the same thing, don't trust discipline to keep them equal. Write the test that fails when they aren't.
Test your own examples by running themTest your own examples by running them
This one cost us the least code and taught us the most.
Our docs show how to override a field: send the new value and get back a customized image. The obvious form is to send the component's key with the new value. We wrote exactly that, it returned a 200 and a valid JPEG, and it looked correct in every language.
It was wrong. The image came back rendering the default text, not ours. The bare key was silently ignored; the renderer wanted the flattened field.text path. Reading the code, you could not tell. The request was well-formed, the response was an image, the status was green. Every signal said "fine."
We only caught it because we run every code sample in its official Docker container against the live API and then look at the pixels. Eight languages, one harness, and a check that the image actually changed. The moment we compared the render to the input, the bug was obvious.
If you publish code samples for an API, run them. Not a mock, not a type-check, the real call against the real service, and verify the output is what you claimed. A sample that returns a 200 is not a sample that works.
Return the image skip the ceremonyReturn the image, skip the ceremony
The render endpoint returns the finished image directly, in one request. No job id, no polling, no webhook to receive the result. That was a product decision that paid off twice over for agents.
Agent-written code stays short. There's no state machine to get wrong, no second request to download, no callback URL to expose. The agent sends a POST and gets bytes. When the thing calling your API is a language model generating code on the fly, every removed step is a step it can't get wrong.
Give the agent a way to see its own workGive the agent a way to see its own work
Writing a design as JSON and hoping is a bad loop. So we exposed a cheap preview render. The agent builds a design, renders a small check image, looks at it, and fixes what's off before it commits.
It turns the agent from a blind JSON generator into something closer to a person in the editor: try, look, adjust. Most of the quality in an agent workflow comes from closing that loop. If your tool produces something visual, give the model a way to see the result at low cost.
Let the standards do the authLet the standards do the auth
Every tool call acts as the user, so auth is not optional. We didn't invent any of it. The agent gets a token over OAuth 2.0, and we layered on the standard MCP authorization spec: RFC 8707 so tokens are bound to the right resource, and RFC 9728 protected-resource metadata so a client can discover how to authenticate without custom setup. The endpoint validates tokens the same way the rest of the API does.
Auth is the part where inventing something clever is a liability, not a feature. The standards exist. Use them, and any compliant client connects with no special-casing.
One more thing metering aligns the incentivesOne more thing: metering aligns the incentives
A practical note, not an engineering one. Agents burn renders. A person clicks a few images; an agent loops over a spreadsheet. Because renders are metered the same as API calls, agent adoption pulls people toward a paid plan faster than a no-code user ever would. Building the agent door turned out to be a growth decision as much as a technical one.
The short versionThe short version
If you're putting an AI agent in front of a real system:
- Generate the agent's view from your source of truth, not a copy.
- Guard any duplicate representation with a test that fails on drift.
- Run your own examples against the live service and check the output.
- Return results directly and cut every step you can.
- Give the model a cheap way to see what it made.
- Use the auth standards instead of inventing your own.
Want to see the result? The AI Agents page is the tour, and the Claude MCP guide walks through a full session. If you'd rather call the renderer directly, the API docs have tested examples in every major language.