GluelyAI TikTok app - Go viral!Try It Now

A Working MCP Server Image Generation Example, Start to Finish

11 min read
A Working MCP Server Image Generation Example, Start to Finish

Almost every MCP server image generation example on GitHub does the same three things: define one image tool, pass the prompt to a hosted model, and hand back a URL. Here is what a complete one looks like, and what the short versions leave out. They work. They are also where most of the useful information stops, because the interesting part of an image tool is not the API call, it is everything that happens around a request that takes fifteen seconds and returns three megabytes of binary data. If you have already read our walkthrough on connecting Claude and ChatGPT to private business data with MCP, the wiring here will feel familiar; the failure modes will not.

The Model Context Protocol is deliberately small. A server advertises tools, each with a name, a human-readable description, and a JSON schema for its inputs. A client such as Claude Desktop, Cursor, or a custom agent reads that list, decides when a tool is relevant, and calls it. Nothing in the spec is specific to images. What makes an image tool different from a database query tool is latency, payload size, and cost per invocation, and all three of those push the same design decisions that show up in any production-grade orchestration API.

This piece walks through a complete example, then spends most of its length on the parts that the README-sized versions leave out. The code is Node with the official TypeScript SDK, but the shape translates directly to the Python SDK if that is your stack.

The tool definition is the prompt

The line that does the most work in an image MCP server is the tool description, because the description is what the model reads when it decides whether to call you. A description that says "Generates an image" will get called for screenshots, diagrams, chart requests, and anything else vaguely visual. A description that says "Generates a photorealistic or illustrated image from a text prompt. Does not edit existing images and cannot render legible text or logos" will get called far more accurately, and the same discipline that applies to writing a good image generation prompt for an API call applies to writing the schema around it.

A minimal but honest definition looks like this:

server.registerTool("generate_image", {
  title: "Generate image",
  description:
    "Generate one image from a text prompt. Photoreal or illustrated. " +
    "Cannot edit an existing image. Cannot reliably render text. " +
    "Takes 8 to 25 seconds. Costs credits on every call.",
  inputSchema: {
    prompt: z.string().min(4).max(1200),
    aspect_ratio: z.enum(["1:1", "16:9", "9:16"]).default("1:1"),
    count: z.number().int().min(1).max(4).default(1),
  },
}, handleGenerate);

Two details matter more than they look. Stating the latency in the description stops well-behaved clients from calling the tool inside a tight loop. Stating the cost gives the model a reason to ask the user before generating four variations instead of one. Neither is enforced by the protocol, and both change agent behavior noticeably in practice.

Open notebook with a hand-drawn wiring diagram beside a coffee cup on a dark desk

The handler, including the part after the API call

Here is the handler most examples ship, and it is fine as far as it goes. It calls the provider, waits, and returns. The version below adds the two things that turn it from a demo into something you can leave running, which are a bounded wait and a durable place to put the result. Teams building on top of hosted endpoints usually hit these same two problems, which is why the production guide to generation APIs for SaaS apps spends as much time on storage as on model selection.

async function handleGenerate({ prompt, aspect_ratio, count }) {
  const job = await provider.submit({ prompt, aspect_ratio, count });
  const result = await pollUntilDone(job.id, { timeoutMs: 90_000 });
  if (result.status !== "completed") {
    return {
      isError: true,
      content: [{ type: "text", text: `Generation ${result.status}: ${result.error ?? "timed out"}` }],
    };
  }
  const stored = await Promise.all(
    result.images.map((img) => putObject(img.url, `gen/${job.id}-${img.index}.png`))
  );
  return {
    content: [
      { type: "text", text: `Generated ${stored.length} image(s) for: ${prompt}` },
      ...stored.map((url) => ({ type: "text", text: url })),
    ],
  };
}

The putObject step is the one people skip. Every hosted image provider returns a signed URL that expires, commonly in one to twenty-four hours. If your server hands that URL straight to the model, the conversation transcript rots: the user scrolls up a day later and every image is a dead link. Copying the bytes to your own bucket on the way out costs one extra request and removes the whole class of problem, and it also gives you a place to attach the metadata you need later for auditing and per-tenant accounting, which the guide to multi-tenant AI image generation covers in more depth.

URL, base64, or both

MCP supports returning an image content block with base64 data, and it is tempting to use it because the image then appears inline in the client. Do the arithmetic first. A 1024x1024 PNG is commonly 1.2 to 2.5 MB, which is roughly 1.6 to 3.3 MB once base64 encoded, and that entire blob enters the model's context window on every subsequent turn of the conversation. Four variations will exhaust a context budget faster than any amount of text.

The pattern that holds up is to return the URL as text by default, and return base64 only when the caller explicitly asks for it through an inline: true input. In a batch of twenty generations we ran through a single tool, the URL-only path kept the transcript under 40 KB while the inline path pushed the same conversation past 12 MB and triggered client-side truncation on two of the three clients we tested. Wireflow's walkthrough of an MCP image generation server reports the same split, recommending URLs for anything a downstream system will consume and inline data only when a human is looking at the result immediately.

Timeouts, retries, and the money

Three failure modes account for almost every broken image MCP server in the wild, and none of them are exotic. The first is an unbounded poll loop that hangs when the provider drops a job, which the client experiences as a frozen tool call with no error. The second is a naive retry that resubmits a prompt after a timeout without checking whether the original job eventually completed, so you pay twice and sometimes return the wrong image. The third is simply having no spend ceiling at all, which turns an agent stuck in a loop into a billing incident; setting hard caps at the key level, as described in this piece on generation APIs with spend limits, is the cheapest insurance available.

Concrete defaults that work: a 90 second wall-clock timeout on the poll, exponential backoff starting at 1 second and capping at 5, idempotency keyed on a hash of the prompt plus parameters so a retry within the window returns the existing job rather than starting a new one, and a per-session counter that refuses the call after N generations and says so in plain text, sized against whatever your provider actually charges per image, which published Flux API pricing puts in the low single-cent range for most tiers. That last one is worth returning as a normal text response rather than an error, because models handle "you have used your 10 generations for this session" far better than they handle an opaque 429.

Vintage camera lens standing alone on a concrete plinth under directional light

Testing before you wire it to a client

Debugging an MCP server through a chat client is miserable because you cannot control when the model calls your tool. Use the MCP Inspector instead, which speaks the protocol directly and lets you invoke tools by hand with arbitrary arguments. Point it at your server with npx @modelcontextprotocol/inspector node ./server.js and you get a browser UI listing every advertised tool and its schema.

The checks worth running before you connect a real client are unglamorous: call the tool with a prompt at the schema's maximum length, call it with count at the upper bound and confirm the timeout behaves, kill the network mid-generation and confirm you get a text error rather than a hang, and call it twice with identical inputs to confirm idempotency actually deduplicates. If you are running the same prompt set across several providers to compare output, the mechanics of batch image generation via API map cleanly onto a test harness for this.

When one model behind one tool stops being enough

The single-tool server is the right starting point and often the right ending point. It stops fitting when the work becomes multi-step: generate a base image, upscale it, remove the background, then composite it against a template. You can expose each of those as a separate MCP tool and let the model orchestrate, but the model then has to hold intermediate URLs across turns and it will occasionally lose one.

The alternative is to keep the orchestration server-side and expose a single tool that runs a named pipeline, which is closer to how node-based image generation systems already work. The MCP tool becomes run_pipeline with a pipeline name and a parameters object, and the multi-step logic lives somewhere it can be versioned and tested. The tradeoff is flexibility: the model can no longer improvise a new sequence, only invoke ones you defined.

FAQ

Do I need a hosted model provider to build this? No. A local model behind an HTTP endpoint works identically from the server's perspective, since the handler only cares about submit and poll semantics. Hosted providers are simply faster to start with, and the code examples for calling Flux from curl and Python transfer to any provider with a similar job API.

Which transport should the server use, stdio or HTTP? Use stdio for a server that runs on the same machine as the client, which covers Claude Desktop and most editor integrations. Use streamable HTTP when the server is shared, remote, or needs to serve multiple users, and note that the shared case is where per-tenant spend tracking stops being optional.

How do I stop the model from generating four images every time? Set count to default to 1 in the schema and say in the tool description that each image costs credits. Models respect stated costs more consistently than they respect instructions buried in a system prompt, a pattern also visible in how agents treat API tooling inside Claude Code.

Can an MCP tool return an image the model can actually see? Yes, through an image content block with base64 data and a mime type, and vision-capable models will interpret it. Budget for the context cost before making it the default, because the encoded payload persists in the conversation.

What is a reasonable timeout? Ninety seconds covers the slow tail of most current image models, which typically finish in eight to twenty-five seconds. Video generation is a different regime entirely and usually needs a job-handle pattern where the tool returns immediately and a second tool checks status, as the timings in this walkthrough of generating video through an API make clear.

How should errors be returned? As a text content block with isError: true and a message a model can act on, such as "prompt rejected by safety filter, rephrase without named public figures". Opaque status codes lead to the model retrying the identical prompt, which is the same argument made for descriptive error payloads in most developer-facing generation APIs.

Is one tool per model better than one tool with a model parameter? One tool with an enum of model names is easier for the model to reason about and keeps the tool list short. Separate tools make sense only when the input schemas genuinely diverge, for example when one model requires a reference image and another does not, which is common in programmatic image generation setups that mix editing and generation.

Wrapping up

A working MCP server image generation example is about sixty lines of code and about six decisions. The code is the easy half. The decisions, which are bounded waits, durable storage, URL over base64, idempotent retries, a hard spend ceiling, and an honest tool description, are what separates a server that demos well from one you can leave connected to an agent overnight. Start with the single tool, ship it, and only reach for pipeline orchestration when the model starts dropping intermediate results on the floor.