← writing

The MCP server I built for my CMS

I wired Claude directly into Squilla through an MCP server, and now editing the site sounds like talking to a coworker who actually does the work.

"Publish the docs page with the diagram on hero," I said into the chat box, and a few seconds later the page was live. No clicking into the admin, no dragging a block into place, no copy-pasting a URL into a featured image field. One sentence in, one published node out. That was the moment I realised the MCP server I had been hacking on for two weekends was no longer a toy.

What it actually is

Squilla is my Go-based CMS. The shape of it is kernel plus extensions, a bit like a tiny Linux for content. The kernel handles nodes, auth, rendering, and a single Go interface I call the CoreAPI. That interface has around thirty five methods spread across fifteen domains, things like create a node, register a node type, upload media, set a menu, emit an event. Extensions are gRPC plugins that own their full stack, including their own database tables and their own React micro-frontends. Everything a feature needs sits inside its own folder.

The MCP server is a separate Go binary that speaks two protocols at once. On one side it talks MCP over stdio so Claude can find it. On the other side it holds a reference to the same CoreAPI the rest of the CMS uses. Tools are mapped one to one onto CoreAPI methods. core.node.create on the Go side becomes a create_node tool on the MCP side. core.media.upload becomes upload_media. core.nodetype.register becomes register_node_type. The mapping is boring on purpose. Boring means I can add a new tool by adding a new CoreAPI method and a thin adapter, nothing more.

Capabilities, not blanket access

The CMS already has a capability system for extensions. Every extension declares what it is allowed to do in its manifest, things like nodes:read or files:write, and the CoreAPI enforces those at every call. I reused the same machinery for MCP sessions. When Claude connects, the session is scoped to a set of capabilities. A read-only assistant gets nodes:read and media:read and nothing else. My personal editor session gets the full keychain. The guard is the same code path that protects extensions, so I did not have to write a second authorisation layer and pray it agreed with the first one.

Why this actually matters to me

I have been building CMSes long enough to know that the bottleneck is rarely the rendering pipeline. The bottleneck is the human in the admin UI, hunting for the right field, second-guessing the SEO slug, forgetting which taxonomy a post should sit under. With MCP wired in, the editor can talk to the CMS in plain English and the model figures out which tool to call. "Make a new docs post in the AI category, draft only, with the screenshot from yesterday's upload as the hero." That is one request. The model picks the right tools, fills the fields, and shows me a preview. I stay in flow.

The other thing I did not expect was how much more useful the CMS feels for non-developers. I gave the MCP endpoint to a friend who runs a small site on Squilla. She does not know what a node type is, and she does not need to. She asks for a new recipe page, the model figures out it should be a recipe node type, populates the ingredients block, sets the cover photo, and asks her to confirm. The CMS finally feels like a thing you operate, not a thing you administer.

Surprises along the way

The model loves to over-fetch. Early on, when I asked it to update the meta titles on five posts, it called get_node eight times before doing a single write. It was treating the API like a chatty REST endpoint instead of a batch system. I added a list_nodes tool that returns the fields the model actually needs in one round trip, and the behaviour fixed itself. The lesson was simple: design the tools for the way the model thinks, not the way your codebase happens to be sliced.

The other surprise was slug hallucination. When I asked for a new node of a custom type, the model would sometimes invent a node type slug that did not exist, then look confused when the create call failed. I added a discover tool that returns the list of available node types with their field schemas. Now the model checks before it creates, and the hallucinations stopped. A tiny tool, a huge quality jump.

What is still rough

Long-running operations are awkward in MCP today. A media import from a remote URL might take fifteen seconds. The protocol wants a request-response shape, and there is no clean way to stream progress back to the model without blocking the session. My current workaround is to return a job ID immediately and let the model poll a status tool, but it feels like I am reinventing webhooks inside a chat protocol. I think the spec will grow into this. For now it is the one place where I have to apologise to the model.

Error surfacing is also clunkier than I would like. A validation failure deep inside the CoreAPI bubbles up as a structured error, but the model often treats it as a wall and stops instead of retrying with a fixed payload. I am experimenting with returning hints in the error body, things like "slug must be lowercase kebab-case, try: my-post-title." Early signs are good. The model takes the hint and tries again.

The bit I keep thinking about

This is the part where AI starts to actually run my software, not just chat about it. For years the demos have been about the model writing code or summarising a doc. Useful, but always one step removed. Wiring the model into the CoreAPI through MCP is different. The model is reaching into the same interface my own admin UI uses. It is editing the same nodes, uploading to the same storage, firing the same events. There is no special AI mode and no parallel codebase. It is just another caller on the bus, with its own capability badge.

If you build software with a clean internal API, you already have most of what you need. The MCP server is a small adapter over a thing you should have anyway. The hard part was never the protocol. The hard part was deciding that the model deserves the same first-class access as my own UI, and then designing the tools so it can actually use them well.

Want more like this?

Occasional, opinionated, no listicles.
all writing →