Skip to main content

Post covers, social cards, and a home-grown image MCP server

· 13 min read
Pere Pages
Software Engineer
A blog post cover being assembled from a title card and an AI-generated illustration

Every post on this blog needs two images that do completely different jobs: a social card that has to earn a click in a feed, and an inline hero that opens the article. This post is about how the blog makes both — a typographic default that costs nothing, an opt-in artificial-intelligence (AI) cover that costs about four cents, and the little Model Context Protocol (MCP) server that wires an image model straight into Claude Code. The cover above was made by that exact pipeline.

Two images, two jobs

It's tempting to think a post has "an image." It has two, and they're not interchangeable.

The social card is the Open Graph (OG) image — the picture that shows up when a link is pasted into X, LinkedIn, Slack, or iMessage. It's set by the image: field in a post's frontmatter, it's a fixed 1200×630, and its only reader is a link-preview scraper feeding a thumbnail into a feed. Nobody studies it; it has a fraction of a second to look deliberate. (Generating the card is one half of the job; confirming it actually renders in each scraper before you publish is the other — I wrote that part up in Testing Open Graph while developing, the sane workflow.)

The inline hero is the image at the top of the article body. It's imported into the post's MDX (Markdown-plus-JSX) source, rendered at reading width, and its audience is a person who has already clicked. It sets the tone of the piece.

Social card (OG)Inline hero
Set byfrontmatter image:an MDX import in the body
Fileimages/og.webpimages/cover.webp
Sizefixed 1200×630body width, capped at 1600px
Audiencea link-preview scrapera reader who already clicked
Jobearn the clickopen the article

Because the jobs differ, the sources differ. The default flow generates the card and leaves the hero alone; the opt-in flow generates one image and serves it as both. The rest of this post is those two flows.

The typographic card is the honest default

By default, a new post's card isn't drawn — it's typeset. scripts/generate-post-og.mjs reads the title straight out of the frontmatter and renders it as a card in the site's own design language: dark indigo background, Space Grotesk, the little pear. It writes images/og.webp at 1200×630 and flips the frontmatter image: line on. The new-post skill runs it as the last step of scaffolding.

The reasoning is about where the card lives. It competes for a click in a feed, at thumbnail size, next to the title it sits beside — a place where the image isn't really read. A title card in the site's own language does that job honestly and stays consistent from post to post. A generic AI illustration, in that slot, mostly reads as low-effort. So the typographic card is the default, and a real illustration is a deliberate opt-in — never the automatic choice.

The design language is shared through scripts/lib/og-template.mjs, which also backs the site-wide fallback card (scripts/generate-og.mjsstatic/img/social.png, used by any post with no image: of its own). One template, two entry points, so every generated card matches.

One catch worth knowing: the template's webfonts load from Google Fonts at render time, so generating a card needs network access. The script throws rather than silently falling back to a system font — a card rendered in the wrong typeface is worse than no card, because it ships looking almost right.

Opting in to a real illustrated cover

Sometimes you actually want an illustration — a real hero that opens the article, not a title card. That's the opt-in path, and it's built around a single idea: generate one image, and derive both artifacts from it, so the card and the hero match.

scripts/post-cover-from-image.mjs takes one source image and writes both outputs:

  • images/og.webp — the 1200×630 card, cropped to fill and centered on the most salient region (via sharp's attention strategy).
  • images/cover.webp — the body hero, the full composition, only downscaled if it's wider than 1600px, never cropped.

Both come out as WebP, which the repo's optimize-images.mjs pass leaves untouched — so these are the final, optimized files, not intermediates. And because the hero keeps the name cover.webp, the scaffolded post's import cover from './images/cover.webp' needs no change; the script just overwrites the placeholder with the real image.

Path B — typographic (default)Path A — illustrated (opt-in)
Card sourcethe post title, typesetone generated image, cropped
Heroplaceholder you swap yourselfthe same generated image, full
Card & hero match?no (independent)yes (one source)
Costfree~$0.04 per image + an API key
Scriptgenerate-post-og.mjspost-cover-from-image.mjs

That cost line is the whole catch, and it deserves its own section.

Free in the browser, paid via the API

Here's the part that trips people up. You can open the Gemini web app (or ChatGPT, or any consumer chat assistant), type "draw me a blog cover," and get an image for free — just by being logged in. So why does the opt-in path cost four cents?

Because a browser tab and an API are different products. The free web app is a person clicking buttons; you can't script it, can't call it from a build step, can't hand it to Claude Code and ask for a file on disk. The moment you want automation — an image generated programmatically, saved to a path, wired into a pipeline — you're on the application programming interface (API), and the API is metered and paid.

For this repo's model, Google's Gemini 2.5 Flash Image, that's roughly $0.04 per generated image, it requires billing enabled on the Google Cloud / AI Studio project, and it needs an API key. The free browser quota doesn't carry over; the API is a separate meter.

So the trade is simple, and worth saying plainly: the browser is free but manual; the API costs money but composes. The typographic default stays free because it's just fonts and sharp. You only reach the meter when you deliberately opt in to a real illustration — which is exactly why it's opt-in.

The image services on offer

If you're going to pay a per-image API, it's worth knowing the landscape. As of mid-2026, the practical options for programmatic image generation look roughly like this:

ServiceModel this repo cares aboutShape
Google Gemini APIGemini 2.5 Flash Image ("Nano Banana")Cheap, fast, good at editorial/illustration; what this repo uses
OpenAI Images APIgpt-image-2High quality, strong prompt-following; priced per image by size/quality
Black Forest LabsFLUX familyAvailable via API or self-hosted; strong photoreal/illustration
Stability AIStable Diffusion / SD3API or fully self-hosted on your own hardware

This repo picked Gemini 2.5 Flash Image for the mundane reasons: it's inexpensive per image, it's quick, and it does clean flat/editorial illustration well — which is exactly what a blog hero wants. None of this is a permanent ruling; prices and models move fast, so the pointer to check current pricing is in the References, not baked into a number here. The important architectural point is that the choice is swappable — the MCP server takes a model parameter, so pointing it at a different Gemini model is a one-word change.

Wiring it into Claude Code with a tiny MCP server

The Model Context Protocol (MCP) is how Claude Code talks to tools it doesn't ship with. A server that speaks it has two jobs: declare its tools — each with a name and a typed input schema, so the client knows what it can call and with which arguments — and answer those calls over a transport. Meet that contract and Claude Code can use the tool as if it were built in. scripts/mcp/gemini-image.mjs meets it in about 150 lines, with no framework beyond the official software development kit (SDK) and zod for the schema.

In code, that contract is only a handful of lines — construct a server, hang the tool off it, and connect it to a transport the client can reach:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({ name: "gemini-image", version: "1.0.0" });
// …registerTool("generate_image", …)…
await server.connect(new StdioServerTransport());

Those two imports are the whole framework surface: McpServer holds the registered tools, and StdioServerTransport is the channel Claude Code talks over — plain standard input/output (stdio), since the server is just a subprocess it spawns. Everything else in the file is the body of that single registerTool call.

First the server has to be registered so Claude Code knows to launch it. That's a few lines in .mcp.json at the repo root — just a command to run:

{
"mcpServers": {
"gemini-image": {
"command": "node",
"args": ["scripts/mcp/gemini-image.mjs"]
}
}
}

On startup Claude Code spawns that process, asks it what tools it has, and gets back a single one — generate_image, taking prompt, out_path, aspect_ratio, and model. From then on, generating one cover looks like this:

So what's expected of the file is four small things, in order:

  1. Load the key. At startup it reads the gitignored .env (covered in the next section) so GEMINI_API_KEY is available. No key, and every call fails fast with a message telling you to add one — better than an opaque authentication error from Gemini.
  2. Declare the tool. registerTool("generate_image", …) hands the client a zod schema for the four parameters. That schema is the contract: it's how Claude Code knows aspect_ratio is an optional string, and it's what makes the tool self-describing without any hand-written docs.
  3. Do the work. When the tool is called, the handler runs a single generate() function: it builds the request body, makes one HyperText Transfer Protocol (HTTP) POST to Gemini's generateContent endpoint, and pulls the first inline image part out of the reply. The image arrives as base64-encoded bytes inside the JavaScript Object Notation (JSON) response, so it decodes that to a Buffer and writes it to out_path, creating parent directories as needed.
  4. Report back. It returns MCP's result shape — a content array — carrying the saved path and byte size, so the caller gets a confirmation it can read back:
return {
content: [
{ type: "text", text: `Saved ${buffer.length} bytes (${mimeType}) to ${dest}` },
],
};

Two details in that flow earned their comments in the source. The first is aspect ratio. You'd think you could ask for a wide image in the prompt — "a wide 16:9 hero" — but the model ignores that and hands back a square. The actual control lives in the generation config:

const payload = { contents: [{ parts: [{ text: prompt }] }] };
if (aspectRatio) {
// Text hints ("wide 16:9") are unreliable — the model ignores them and
// returns square. imageConfig.aspectRatio is the actual control.
payload.generationConfig = { imageConfig: { aspectRatio } };
}

The second is failure. When no image comes back it's usually a safety block or a text-only refusal, so instead of a generic error the handler returns { isError: true } carrying Gemini's own response — the finish reason and any text parts — so the caller can see why nothing was drawn. A bad key, a blocked prompt, and an exhausted quota each say something different and specific.

One last structural touch: the server only opens the stdio transport when the file is run directly (node scripts/mcp/gemini-image.mjs). Imported instead — say, by a test — it just exposes generate() without starting a server, so the Gemini call can be exercised on its own.

Keeping the key out of git

An API key is a secret with a bill attached, so where it lives matters. This one lives in a gitignored .env file at the repo root and nowhere else:

# .env (gitignored — never committed)
GEMINI_API_KEY=your-key-here

A committed .env.example documents the shape without the value, so a fresh clone knows what to fill in. The server loads the file itself at startup using Node's built-in process.loadEnvFile — no dotenv dependency — and shrugs if the file is absent, falling back to whatever's already in the environment:

try {
process.loadEnvFile(resolve(REPO_ROOT, ".env"));
} catch {
// no .env file — fine, fall back to the ambient environment
}

The point is that the secret exists in exactly one untracked file. It never goes in the shell history, never in .mcp.json, never in git. Two operational notes: the API project needs billing enabled before any call succeeds, and Claude Code only picks up a new or changed MCP server on restart — edit .mcp.json or add the key, then restart, or the tool won't be connected.

What it looks like end to end

Put together, scaffolding a post with the new-post skill runs one of two branches:

For the illustrated path, the skill drafts a prompt from the title — always telling the model "no text, no words, no letters," since it renders type unreliably — generates the image to a temp path outside the repo, and shows it to you. If it's not right, regenerate (another four cents) or fall back to the free typographic card. If it's good, derive both artifacts and activate the frontmatter.

That's precisely how the cover on this post was made: one prompt describing a title card and an illustration wired through a server icon, one generation, both artifacts derived, image: switched on. The post about the pipeline is a product of the pipeline.

References