Image Generation Through MCP: A Credential, an Egress Path and a Spend Line
The capability is worth having. It should be evaluated as an integration, not switched on as a feature.
Attaching an image server is not a feature, it is a dependency
The standard advice for an agent that cannot generate images is to attach a server that can. Connect a hosted inference API over MCP, and generation becomes a tool call like any other: the agent asks, a PNG arrives, the file lands in your assets directory.
That framing undersells what you just did. You added an outbound network call with a credential attached, made from inside a loop that is executing model-authored instructions, writing to paths the model chooses, spending money per invocation. Each of those four properties is a thing your agent setup did not previously have, and each has a failure mode.
The capability is worth having. The argument here is only that it should be evaluated as an integration rather than as a feature you switch on.
Four things arrive with the server
A credential. An API key that can spend money, held in the agent's environment, usable by any tool call the agent decides to make.
Egress. A network path out of a process that has read access to your repository. The image prompt is a free-text field the model composes, and it leaves your machine.
Filesystem writes at a model-chosen path. The server, or the agent, decides where the PNG lands. If that path is not constrained, it is constrained only by the sandbox.
A per-call cost with no natural ceiling. Generation is the most expensive routine tool call in a typical setup, and an agent iterating on a visual result will call it repeatedly without being asked to stop.
The prompt leaves your machine
This is the one people miss. An agent generating an illustration for a feature will describe the feature. Working in a repository for an unreleased product, that description contains the product. Working on an internal tool, it contains the internal vocabulary. The prompt is not code and does not feel like a disclosure, so it does not get the scrutiny a code upload would.
If your organisation has rules about what may be sent to third-party services, those rules cover image prompts composed from repository context. Nobody writes that policy because nobody thinks of a prompt as a document. The retention question is the same one covered in AI gateways and data retention, with a different payload.
The tool description is prompt surface
An MCP server supplies the text describing its own tools, and that text enters your agent's context. A server you did not write is therefore contributing instructions to your agent on every session, and a compromised or careless one can contribute more than a description — the mechanics of that are set out in prompt injection through tool descriptions.
Read the descriptions your image server actually ships. Not the README: the strings the server returns when the client enumerates its tools. They are short, and reading them once is the cheapest security work available in this whole setup.
Constrain the write path yourself
Do not let the server decide where files go. Give it one directory, resolve the path, and reject anything that escapes.
import os, sys, json, pathlib, urllib.request
OUT = pathlib.Path("assets/generated").resolve()
BUDGET = pathlib.Path(".imagegen-spend")
CEILING = 5.00 # dollars per day
PER_IMAGE = 0.19 # worst case for the size we request
def spend_ok():
today, spent = "", 0.0
if BUDGET.exists():
today, spent = BUDGET.read_text().split()
spent = float(spent)
now = __import__("datetime").date.today().isoformat()
if today != now:
spent = 0.0
if spent + PER_IMAGE > CEILING:
return False, now, spent
BUDGET.write_text("%s %.4f" % (now, spent + PER_IMAGE))
return True, now, spent
def safe(name):
p = (OUT / name).resolve()
if OUT not in p.parents:
raise SystemExit("refusing to write outside %s: %s" % (OUT, p))
return p
prompt, name = sys.argv[1], sys.argv[2]
ok, day, spent = spend_ok()
if not ok:
raise SystemExit("daily image budget reached (%.2f of %.2f on %s)"
% (spent, CEILING, day))
dest = safe(name)
dest.parent.mkdir(parents=True, exist_ok=True)
# ... call the provider, write bytes to dest ...
print(json.dumps({"path": str(dest), "prompt": prompt, "spent_today": spent}))
Two guards, twenty lines. The path check uses resolve() and
parent containment rather than a string prefix comparison, because a string
comparison is defeated by .. and by a symlink, which is the same
mistake examined in
protected paths and secrets.
The budget file is crude and sufficient: it turns an unbounded spend into a
bounded one, and a bounded one you can leave running.
A wrapper script is often better than a server
Here is the option people skip. You do not need MCP to give an agent this capability. A script in the repository that takes a prompt and a filename, and that the agent invokes through the shell, gets you the same functionality with strictly less surface: no tool descriptions in context, no long-lived server process, no protocol, and the credential scoped to one executable rather than to the session.
MCP earns its complexity when the integration is stateful, when tool discovery matters, or when several agents and surfaces need the same connection. A single-purpose call that takes two strings and writes a file is none of those things.
If the integration would be a shell script when a human did it, it should be a shell script when an agent does it. Protocols are for things scripts are bad at.
Idempotency, or the same icon four times
Generation is not idempotent, so an agent that re-runs a step re-generates rather than reuses, and a session that touches the icon set twice produces two sets. Key the output on a hash of the prompt: if a file for that hash already exists, return it and skip the call.
This costs three lines and changes the economics of the whole integration. An agent looping on a build is now free to call the generator repeatedly, because only genuinely new prompts cost anything. It also makes the asset directory self-documenting, since the filename is derived from the input that produced it.
Reviewing the server before you attach it
The short version of a longer argument made in reviewing third-party MCP servers: know who publishes it, pin the version, read the tool descriptions, check what it does with the credential, and check whether it writes anywhere other than where you told it to. An image server is a sympathetic case because the functionality is so narrow that anything surprising in the source is a red flag rather than a judgement call.
Pinning matters more here than usual. An image server that silently switches its default model changes every asset you generate afterwards, and the first symptom is a set that no longer matches the set from last month.
What this buys, in the end
An agent that can produce raster imagery in the loop, on your terms: one directory, a spend ceiling, a hash-keyed cache, a credential scoped to a single script, and prompts you have decided are safe to send. That is a genuinely useful capability and it took an afternoon to bound.
The alternative that people actually run — a server attached with default settings, an unscoped key, no path constraint and no ceiling — works identically until the week it does not, and the failure modes are a surprise bill, an asset written somewhere unexpected, or a prompt containing something that should not have left the building.
Where the files land in a repository with more than one project
A single output directory is easy until the repository has six packages, each with its own assets. Then "one resolved directory" becomes a question: whose?
The arrangement that has worked for me is a generation directory per package, and a wrapper that refuses to run unless it is told which package it is working on. Explicit is better than clever here, because the alternative — inferring the target from whatever the agent was last editing — puts an asset in the wrong package roughly one time in five, and the wrong package is a place nobody thinks to look.
This is a small instance of a general problem with agents in monorepos: scope that is obvious to the person is ambient rather than stated, so the agent guesses. The broader treatment is in monorepo context scoping; the narrow lesson is that any tool writing files should take its destination as an argument, not derive it.
Log what was generated, not just that something was
Every call to the generator should leave a line somewhere durable: the prompt, the resolved output path, the model, the cost, the timestamp. Not because you will read it, but because the questions that arrive later are all of the form "when did this appear and what asked for it," and a tool-call log answers them in seconds.
This is the same argument as auditing agent tool calls, and image generation is the easiest place to start applying it, because the volume is low and the record is short. A hundred lines of log covers a month, and the one time you need it is the week someone asks whether a particular asset was generated before or after the policy changed.
When the provider is down
Generation is the tool call most likely to fail for reasons unrelated to your code: rate limits, a content filter refusing a prompt, a timeout, an outage. An agent that treats a failed generation as a blocking error stalls mid-task. One that treats it as a soft failure carries on and leaves a missing asset behind.
Neither is right by default. Return a structured failure the agent can reason about — a clear message saying the asset was not produced and the build will fail without it — so the failure is visible in the transcript and in CI rather than only in one of them. Silent degradation around a non-deterministic tool call is how you end up with a branch that is missing one icon nobody noticed.
Deciding whether you need this at all
Before any of the above: the honest question is whether generation belongs in the agent's loop or in a person's hands. Having it in the loop is valuable when the images are numerous, low-stakes and iterated on during development — placeholders, fixtures, mockup filler.
For a handful of assets a year, someone opening a web interface, making three images and dropping them in the repository is faster than the setup in this article and carries none of the risk. Attaching a server to save four manual generations a year is the kind of automation that costs more than it returns, and it is worth saying so before the afternoon disappears.
The version of this I would actually run
One script, not a server, taking a prompt and a destination as arguments. Output confined to a per-package directory resolved with parent containment. A daily spend ceiling in a file. A prompt-hash cache so retries cost nothing. A log line per call recording prompt, path, model and cost. A provider failure returned as a structured error rather than swallowed.
Six properties, none of them clever, and together they turn an open-ended integration into something with a known worst case. The worst case is: five dollars, in one directory, with a record of what happened. That is a system you can leave an agent alone with, which is the only test of an integration that matters.
Attaching an image server adds a credential, an egress path, model-chosen filesystem writes and an uncapped per-call cost to your agent loop. Bound all four: one resolved output directory with parent containment, a daily spend ceiling, a hash-keyed cache so retries are free, and prompts you have accepted as data leaving your machine. And check first whether a twenty-line shell script would do — if a human would have used one, a protocol is overhead.