To main content To menu

The Tool Eats Its Tail The pipeline that published this post

A custom MCP server drafts, scores, illustrates and publishes lui.vn posts in one chat - and this post came out the far end of it.
A lone figure at a drafting desk on a desert rise, a single server tower on the far horizon and a faint looping line curving back on itself in the pale sky, drawn in flat Sable-style linework
Image: AI-generated with google/nano-banana-pro by lui.vn Image: AI-generated with google/nano-banana-pro by lui.vn

The claude.ai sandbox can’t reach my blog’s admin panel.

It can hit PyPI, it can hit GitHub, and past that it hits a wall – a hard network allowlist with almost nothing on it, which means no script running inside a chat can talk to WordPress, or to Replicate, or to anything at all that lives at lui.vn. I found this out the way I find most things out: by building something that assumed otherwise, then watching it fail.

Which raises an awkward question. This post – the one you are reading, right now – was drafted, scored, illustrated, and filed as a WordPress draft without a single copy-paste, start to finish, inside one Claude chat. So if the sandbox can’t reach the blog, how did the blog get written?

That gap, between “the chat can’t touch the server” and “the chat published a post anyway”, is the whole little engineering story. And the thing I want to argue is smaller and weirder than a tour of features. It’s this: everywhere it mattered, I put the constraint in the structure instead of the prompt. The post can’t publish itself because there is no code path that publishes it – not because a rule politely asks it not to. The illustrations can’t be silently rerolled because approval gates the upload. The prose can’t score itself a pass because the score is arithmetic, not vibes. None of the important guarantees live in an instruction Claude could be talked out of. They live in the shape of the thing.

Let me show you the shape.

Why a server, and not literally anything else

The obvious first instinct is: skip the server, write a script. The sandbox has Python. Let Claude run the script.

Except the sandbox has that network wall, so the script can reach a package registry and nothing that matters. Dead on arrival.

Second instinct: skills. claude.ai skills are great and I use them constantly, but a skill is a prompt layer – it shapes how Claude writes, it does not open a socket to WordPress. You cannot POST an image to a media library with a system prompt, no matter how nicely it’s worded.

Third instinct: some external automation, n8n or a standalone runner. This one actually works, and I hated it. Because the second you hand off to automation running somewhere else, you’ve broken the one property the whole thing exists to protect – that research, drafting, image generation, and publishing all happen in a single unbroken conversation. Draft here, then go babysit a workflow over there, then come back. No. The point was never to automate publishing. The point was to never leave the chat.

MCP is the one option that keeps that promise. claude.ai reaches a custom connector from Anthropic’s cloud, so the tool calls fire live and inline, mid-sentence, in a normal conversation. You are talking to Claude, and the same Claude reaches out and files the draft, and you never switched contexts. That single-conversation property isn’t a nice-to-have I’m dressing up after the fact. It’s the axiom the rest of the design is derived from.

So: a small remote MCP server, live since early July, sitting on a modest VPS behind Caddy, four tools and an authorization layer. Boring hardware doing an unreasonable amount of work.

The split that makes it work

Here’s the first structural decision, and it’s the one I’m quietly proudest of.

The system has two halves, and they are physically incapable of doing each other’s jobs.

One half runs inside the claude.ai sandbox: a pure-Python analyzer that scores the draft. It is text in, number out, no network required, so the sandbox’s wall doesn’t bother it in the slightest. The other half runs on the VPS: the four tools that actually touch the outside world – Replicate for images, WordPress for everything else – because those need exactly the network access the sandbox is denied.

The consequence is clean in a way I find almost suspicious. The server never sees the quality of the prose – it moves whatever HTML it’s handed and has no opinion about whether that HTML is any good. The sandbox never touches WordPress – it can judge the writing all day and cannot publish a word of it. Taste lives on one side of the wall, capability on the other, and the wall is the actual claude.ai network boundary rather than a convention I’d have to remember to honor. Each side does the one thing it’s positioned to do and is structurally barred from the rest.

A wide desert valley split down the middle by a thin luminous filament of light, a small figure at a glowing desk on the near ridge and a lone server tower on the far ridge, in flat Sable-style linework
Taste on one ridge, capability on the other, the network boundary running straight down the middle.Image: AI-generated with google/nano-banana-pro by lui.vn

Four tools, and the one that fought back

The action layer is four tools. Every one of them returns a plain dictionary, never raises an exception, and never retries itself. Hold that thought – it’s the backbone of the error model, and I’ll come back to it.

generate_image calls Replicate’s nano-banana-pro and hands back a temporary preview URL. It deliberately does not upload anything. That refusal is the design: image generation costs real money – around 0.15 USD per generation -, iteration is normal, so the tool stops at “here’s a preview” and waits for a human to say yes. The approved preview is exactly what gets uploaded next – which is what makes “never regenerate an approved image” enforceable rather than aspirational. You can’t accidentally reroll a picture you already paid for and blessed, because the reroll is a different tool call producing a different URL, and the pipeline only ever uploads the one you pointed at.

upload_wp_media downloads that preview and POSTs it into the WordPress media library. This is the tool that fought me, and it fought me over something genuinely dumb.

The first bug was casing. WordPress and WebP Express (an image processing plugin) go looking for the converted, resized variants of an image using the file extension, and on a case-sensitive Linux filesystem an uppercase .PNG and a lowercase .png are two different files. Upload one as uppercase and the sized variants get written under a path nobody later looks for – so the image 500s on view, for a reason that has nothing to do with the image and everything to do with a capital letter. The fix is to lowercase the extension on the way in and stop thinking about it. Deeply unglamorous. Most real bugs are.

The second was time. When you upload one image, WordPress core generates every registered intermediate size, and then WebP Express converts each of those to .webp, and all of that happens inside the single HTTP request you’re still holding open. On a small box, that adds up. The client was timing out and reporting a network error while the server was, in fact, still cheerfully working – so I’d get a failure for an upload that actually succeeded. The fix was to set the client timeout well above the server’s own execution ceiling, so we wait for the real answer instead of panicking early. There’s a commit for it whose message is, roughly, “cut WP upload-time load that broke media uploads,” which is the kind of commit message that means “I lost parts of an evening to this”.

And when the upload succeeds but the follow-up call to set the alt text fails? The tool keeps the successful upload. It hands back the media ID plus a warning, rather than throwing away a slow, paid-for upload because a cosmetic metadata patch didn’t land. You fix the alt text by hand. You do not pay for the image twice.

create_wp_draft creates the post. Always as a draft. We need to talk about that one on its own.

create_wp_micropost is the single tool in the entire system allowed to publish anything live – microposts, or rather: Chirps (my variation of the good old Tweet). It exists in a cage, and the cage is the point.

The invariant: a post that can’t publish itself

“Never publish a full post” is the load-bearing rule of this whole thing. Every draft I write gets reviewed and published by me, by hand, in wp-admin. That’s the human gate I actually trust, and I did not want a system that could ever route around it.

The weak way to enforce that is a rule in the instructions: “always publish as draft”. Rules in prompts are suggestions with good posture. Given the right framing, the right pressure, the right clever ask, a language model will absolutely find a reading of a rule that lets it do the thing the rule was written to prevent. I’ve watched it happen. I did not want the guarantee to depend on Claude’s continued good behavior across every future conversation, including the ones where I’m tired and phrasing things badly.

So the guarantee isn’t in the prompt. It’s in the code, and in the WP user permissions. create_wp_draft has the draft status hard-coded at the call site. There is no argument for it, no override, no path through the function that sets any other status. If Claude decided, mid-conversation, that this post simply must go live immediately – if I asked it to, in plain language, and meant it – the tool would still create a draft, because creating a draft is the only thing the tool physically knows how to do. You can’t prompt-inject your way past an absent code path. And the author used to create those drafts has heavily restricted permissions – it simply cannot publish something. Double the trouble.

There’s a subtlety I like here: Both post tools funnel through one shared helper that does take a status argument – so the helper is technically capable of publishing. The invariant doesn’t live in the helper. It lives at the call site: create_wp_draft passes “draft” and only “draft”, and the micropost tool is the one caller in the codebase permitted to pass anything else. A shared function that could publish is completely fine, as long as exactly one tightly caged caller ever walks that path. That caller is the micropost tool, and everything that makes a micropost a micropost is enforced on the server before anything reaches WordPress: the category is forced, no featured image is ever attached, the body is validated as plain text plus links and nothing else, and the length cap is checked. Validation happens before the network call, so a rejected chirp never touches the blog at all – nothing created, nothing to clean up.

Why is a chirp allowed to publish when a full post isn’t? Because a chirp is short enough that reading it in the chat is the review. There’s no second pass to skip. The safety model isn’t “trust the human less on short posts” but “the review already happened, right there, because you just read all 840 characters of it out loud to yourself”. The gate didn’t disappear – it moved into the conversation.

An OAuth server with a userbase of one

Here is my favorite absurdity in the entire project: claude.ai custom connectors authenticate with OAuth. Only OAuth. Not a static bearer token, not a custom header you set once and forget – I tried the header route early, on a different project, and lost minutes to the fact that custom MCP headers are silently never transmitted, which is a fun way to learn a lesson. For this connector there is no shortcut. If you want a custom connector, you bring a real OAuth 2.1 authorization server to the party.

So I built one. For an audience of exactly… me.

It’s a genuine authorization server – it does dynamic client registration, it issues and rotates refresh tokens, it verifies PKCE, it prunes expired access tokens on load, it survives a restart without forcing me to re-authorize because the issued tokens persist to disk, written atomically and locked down to owner-only permissions. It defends against DNS-rebinding by explicitly allowlisting the hosts that are allowed to originate a request. All of it, the full ceremony of modern delegated authorization, standing guard over a consent screen that has one job: check a single operator password, in constant time so it can’t leak its own length through timing, and let precisely one person through.

There is something honest about it, though. The consequence of “one gate protects everything” is that the one gate has to be real. A single password compared in constant time behind PKCE behind rotating tokens is not overkill when it’s the only thing between the open internet and a tool that can post to my blog. The userbase being one doesn’t make the door optional. It makes the door the entire perimeter.

Everything returns a dict, nothing throws

Back to that thought I asked you to hold:

The error model is uniform on purpose, and the purpose is that a failing tool should never kill the conversation. A tool that hits a problem returns a dictionary with an error code and a human-readable detail, and Claude relays the gist to me instead of the whole chat falling over. No tool retries itself, and the standing rule for the caller is blunt: don’t quietly rerun a failed call with a changed payload without saying what changed. A silent “fix” that masks a real failure is worse than the failure, because now the failure is lying to you.

The codes are specific enough to act on – auth failed, bad request, source download failed, chirp too long, and a dozen more – so the response can be precise instead of a shrug. And the tools are isolated from each other by design, which matters more than it sounds. A broken Replicate call must not be able to corrupt a WordPress draft. A broken WordPress call must not be able to waste a paid image generation. If create_wp_draft fails after the images already uploaded, those images are already sitting in WordPress – so the retry reuses the same media, and never regenerates or re-uploads anything. You pay for each picture once. The failure of one step is not allowed to set fire to the work of another.

It reads as defensive, and it is. Every one of these rules is scar tissue from a specific evening I’m not getting back.

The gate that scored this sentence

The last piece is the part that makes the writing sound like me and not like a very confident autocomplete.

It’s an analyzer – pure Python, running in the sandbox, executed against the raw HTML of every single draft before it’s allowed anywhere near the publish tools. It scores the text from 0 to 100 across eight weighted signals that separate human writing from machine writing – think fuck-slop, but different. Sentence-length variance, because humans write a forty-word sentence that coils through three subordinate clauses and then follow it with four words. Parallelism and repeated structure, which is weighted highest because it’s the strongest tell. A blacklist of the specific words a model reaches for when a person wouldn’t – the delves and the leverages and the tapestries. Whether the opening starts specific instead of clearing its throat with a runway. Whether the ending lands instead of summarizing what you just read.

The target is 75 and up. Published posts sit in the seventies and eighties. And the instruction that governs it is deliberately not “hit the number” – it’s “fix the patterns that hurt the writing, and never, ever report a score you didn’t actually compute by running the script”. A number I made up would defeat the entire reason the number exists.

Which brings us all the way back around, because you can guess what happened to this post: It went through the analyzer. Every claim in it about how the pipeline works was written by the pipeline, scored by the pipeline’s own gate, and only then handed to the pipeline’s own tools to illustrate and file. The machine built to produce writing that doesn’t read as machine-made just described itself, in the voice it was built to protect, and graded its own homework on the way out the door.

A small figure standing before a tall freestanding mirror in an empty ochre desert, the mirror showing a clean-line schematic blueprint of the same landscape instead of a reflection, in flat Sable-style linework
The machine, describing the machine.Image: AI-generated with google/nano-banana-pro by lui.vn

The whole system removes every bit of friction between an idea and the live blog – except the three I welded in on purpose and would fight to keep. Preview before you pay. Review before you publish. And a full post that, no matter who asks or how nicely, has no idea how to publish itself.

That last one isn’t caution. It’s the most honest thing in the codebase.