Codex vs Claude

Image Generation Through MCP: A Credential, an Egress Path and a Spend Line

David Guzenburg/ / 10 min read

The capability is worth having. It should be evaluated as an integration, not switched on as a feature.

MCPtoolingcredentialsintegration

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.

Prompts are data egress

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.

The test I use

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.

Takeaway

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.

Keep reading
Codex vs Claude

What a Tool Protocol Standardises, and What It Leaves to You

Connection and discovery are solved; tool design is not. Context cost per attached server, why a CLI often beats a server, error messages as agent input, splitting read from write, and the small server over your own systems that nobody else can write.

Tooling & Integration

Building a Local MCP Server: Exposing What the Filesystem Cannot Answer

A working TypeScript MCP server over stdio, why the tool description decides whether it is ever called, returning errors the model can read, and the read-only role to create before it runs.

Tooling & Integration

Giving Claude a Second Opinion: Connecting Gemini Through a Local MCP Server

A working walkthrough for wiring another model into Claude Code as a tool: the server code, the registration command, the flag ordering that breaks it, and why the context parameter is the part people get wrong.

Codex vs Claude

Reading the Design File: Precise Values Are Not the Same as Tokens

Connecting an agent to the design tool replaces measured pixels with read properties, and the benefit depends entirely on whether the file's variable names map to your code's tokens. The mapping manifest, generating from variables rather than frames, and pulling through a reviewed pull request.

← Screenshot-Driven UI Debugging: The Picture Is Evidence, Not a Diagnosis  ·  Local Execution Is Not Local Inference: Stating the Boundary Accurately →

All codex vs claude articles  ·  Every article