Why I Wrote My First MCP Server
I had a dashboard that ran WordPress maintenance for 50 clients. I didn't have a way to just ask it what was going on.
I built a tool that runs WordPress maintenance for 50+ clients on its own. Backups, updates, error scans, visual regression, rollbacks. I wrote about it here. It works.
But operating it still meant me. And the thing that ate the most time wasn't any single site. It was questions that spanned all of them.
A single client is one glance: open it, read the run, done. Fifty clients is fifty glances stitched together in your head. "Which sites failed their last run, and on what step." "Which ones are still missing a staging environment." "Which Cloudways sites came back dirty on the last malware scan." Every one of those answers lived in the dashboard already. None of them was a screen you could just look at. You assembled it by hand, one client at a time.
The dashboard was good at doing maintenance to one site and bad at answering questions across fifty. Those are different jobs, and only one of them had an interface.
So I gave it an MCP server. It was my first one. Here's how it's actually built.
The mount
MCP (Model Context Protocol) is a standard for handing an LLM real tools: typed inputs, structured outputs, invoked mid-conversation. The dashboard already had the logic behind a REST API. The MCP server is a second caller in front of the same code, one that speaks English instead of clicks.
It's one route on the existing Express backend, running the SDK's Streamable HTTP transport in stateless mode:
router.post('/', async (req, res) => {
const server = buildMcpServer(req.mcpAuth);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no session ids
enableJsonResponse: true, // plain JSON, no SSE stream
});
res.on('close', () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});Fresh server and transport per request, torn down on close. Stateless is deliberate. The backend redeploys on Render every push, so anything holding session state across requests is a liability. Per-request setup costs nothing next to the SSH and WP-CLI work the tools actually do, so there's no reason to keep a session alive. GET and DELETE return 405; there's no SSE stream to resume and no session to terminate.
Auth is a bearer API key, checked before the transport ever runs. Keys are minted on the dashboard's API Keys page (better-auth's apiKey plugin), and a single verifyApiKey call enforces the per-key rate limit, expiry, and enabled flag all at once. Verified requests get the key and user id attached, which is what the audit log reads later. There's no separate gate to maintain.
The tools are thin
Twenty-six tools across four files. Every one is a wrapper. It validates inputs with Zod, calls the exact query or action the UI already calls, and formats the result. No maintenance logic lives in the MCP layer.
server.registerTool(
'trigger_maintenance',
{
description:
'Queue a maintenance run. Starts as "pending"; unless bypassSlack is ' +
'true a human must approve in Slack before the pipeline runs. 5-15+ min.',
inputSchema: {
clientId: z.string().uuid(),
bypassSlack: z.boolean().optional().default(false),
},
annotations: { destructiveHint: true, openWorldHint: true },
},
async ({ clientId, bypassSlack }) =>
toolHandler(async () => jsonResult(await triggerMaintenance(clientId, bypassSlack))),
);triggerMaintenance is the same function the Trigger button calls. That reuse is why this took a weekend. I wasn't building behavior, I was exposing it. The interesting decisions were all in the four things wrapped around every tool.
1. Output shaped for a token budget
jsonResult returns the payload twice: once as structuredContent (which the spec requires to be an object, so arrays and scalars get wrapped in { result }), and once as minified JSON text. No pretty-printing. Indentation roughly doubles the token cost of a 50-client list payload and the model gains nothing from whitespace.
toolHandler maps thrown errors to an isError result carrying the same message the REST API would return. ActionError from the shared action layer passes straight through, so the model reads the identical failure text a human would see in the UI.
2. One call, not fifty
The naive version of "how's the fleet doing" is list_runs fifty times. So there's a dedicated fleet_status tool: one read-only call that returns every client's latest run. Failed runs carry a statusReason, the step that broke plus its message, so the model triages "which clients failed and why" without opening a single log. Same data the dashboard had, reshaped so the consumer answers the question in one round trip instead of fifty.
3. Secrets never leave in a result
Client rows carry decrypted WP Engine credentials internally. Every tool that returns a client runs it through sanitizeClient first, which strips the credential fields and replaces them with a boolean:
const { wpeApiUser, wpeApiPassword, wpeSshKey, ...rest } = client;
return { ...rest, hasCustomWpeCredentials: Boolean(wpeApiUser || wpeApiPassword || wpeSshKey) };The model can still reason about whether a client overrides the global credentials. It just never sees a key.
4. Every call is auditable, and marked by risk
Tools declare what kind of action they are. Read-only tools get readOnlyHint. Anything that touches a live host or deletes data gets destructiveHint (and openWorldHint when it hits an external API). The caller is non-deterministic; the annotations are how it knows trigger_maintenance and clear_run_history deserve more caution than list_clients.
Logging isn't per-tool. It wraps registerTool once, so every tool registered after it emits an audit line on completion and no one has to remember to add it. The line records which key, which tool, what arguments, and how it went, timing included. A JSON replacer redacts wpeApiPassword and wpeSshKey before anything is written and truncates the args at 400 chars. Render captures stdout, so it lands next to the existing server logs.
The part I didn't expect
A UI label is a hint. The human fills in the rest from context they can see on screen. An MCP tool has no screen. The description is the interface, and the model knows only what you wrote. So the server ships an instructions block that reads like onboarding for a new hire:
- trigger_maintenance queues a run as "pending". A scheduler dispatches
queued runs every minute, max 2 concurrent.
- Statuses: pending → awaiting_approval → running → success | error | halted.
- fleet_status returns the latest run for EVERY client in one call.
I never wrote that for the UI. The UI shows you the status flow; you watch it happen. The model can't watch, so it has to be told. Half of a good MCP server is engineering. The other half is writing docs precise enough for a very literal reader who won't infer anything you left out.
What it's actually for
I expected to use it to trigger runs. I barely do. What I use it for constantly is pulling one answer out of fifty sites at once.
The tools are small and single-purpose on purpose, because that's what lets the model compose them. Ask "which sites failed this month and are any of them also throwing malware warnings" and it doesn't need a report I built for that exact question. It calls fleet_status, filters to the failures, then calls run_sucuri_scan on each of those domains and hands back the overlap. Two sites. Named. In one message.
That's the shift. Before, a cross-site question meant I was the query engine: open a client, note the answer, open the next, hold the running total in my head, repeat forty-nine more times. Now the model fans out across the fleet and I read the summary. The dashboard stopped being fifty screens I scan and became one thing I ask.
Some of the questions I actually run:
- "Which clients are missing a staging environment."
list_clientsalready surfacesmissingEnvironmentsper client; the model just filters. - "Which sites are still on the standard pipeline vs. a custom one." A read across
get_client. - "Show me Core Web Vitals for every WPE site, worst first." Fan out over
get_cwv_scores, sort.
None of that needed a new feature. The data was always there. It needed an interface that could ask fifty sites the same question at once, and MCP turned out to be that interface.
And writing tools for a model with no context is clarifying. If you can't describe your system precisely enough for it to operate, you don't understand it as well as you thought. First one. Not the last.
If you're running a system you'd rather talk to than click through, get in touch ↗︎. You can also see the project it's bolted onto, or the first post about how it got built.

Micah Shu
Software developer based in Berthoud, CO. I build fast, well-crafted websites and apps for founders and creative businesses.
More about Micah ↗︎