nikblog-image-mcp, Gemini, Antigravity

00 — Why this project, and how it's going to work

00 — Why this project, and how it's going to work

I already had two MCP servers: nikblog-mcp (read-only, exposes my portfolio as tools) and career-watch-mcp (write operations, job-search findings). This third one closes a real, repeated manual step instead of being practice for its own sake.

The actual problem

Giving a Nik-Blog project or note a cover image is five manual steps: write a Gemini image prompt, generate it in Antigravity, download the file, open the admin panel, upload it through the form. The first four steps genuinely need a human (choosing what the image should look like is my call, not something to automate away). The fifth one — moving a file from my disk into the right place — doesn't need me at all.

The architecture

Three pieces, each with one job:

Why I read the real Nik-Blog code before designing anything

Before deciding how this server should work, I went into the actual nik-blog repo and checked, instead of assuming:

Both of those meant the full loop — upload image, then point the project/note at it — was already possible through Nik-Blog's existing API, with zero changes needed to Nik-Blog itself.

The AWS decision: a dedicated IAM user, not reusing the app's own

Nik-Blog's backend already has an IAM user (nikblog-backend) scoped to s3:PutObject/s3:DeleteObject on nikblog-images/*. I could have reused those credentials here. I didn't, for one real reason: this repo is going to be public, and credentials for one project's live backend have no business sitting in a .env for a separate, unrelated repo — even a gitignored one. A leaked or misconfigured .env in this project should never be able to touch the actual production app's access.

So: a new IAM user, nikblog-image-mcp, with its own customer-managed policy (NikblogImageMcpS3Write), scoped to exactly the same two actions on the same one bucket — same blast radius as the app's own user, but a completely separate credential.

A design decision I walked back: delete vs. keep the old image

My first instinct when "overwriting" a project's image was to also delete the old S3 object, so the bucket doesn't fill up with orphaned files. I reconsidered: S3 storage for a few dozen images costs a few cents a month at most, and deleting is irreversible while leaving an unused file costs nothing real. So the policy still includes s3:DeleteObject in case it's useful later, but the actual upload tool's current design never calls it — "overwrite" means uploading a new image and repointing imageUrl, not destroying the old object.

The overwrite guard — enforced by the tool, not by memory

list_projects/list_notes report whether a project or note already has an image. upload_project_image/upload_note_image check that themselves before writing, and refuse unless overwrite=True is passed explicitly. Same philosophy as career-watch-mcp's schema-enforced job_url: the rule lives in the code the tool runs, not in something the calling agent has to remember to check.

Local file path, not base64

Antigravity downloads the generated image to disk before any tool call happens, so the simplest and most honest design is a tool that takes a file path, not one that expects raw image bytes passed through the MCP protocol — base64-encoding a binary file for transport adds roughly a third to its size for no benefit here, and isn't how the image actually reaches me anyway.