Self-Hosting an MCP Server: Patterns and Pitfalls
Standing up your own Model Context Protocol server is easy. Keeping it safe and scalable is the hard part. The transport, auth, tool, and long-running-work decisions we'd make again.
You can stand up a working MCP server in an afternoon. The SDK gives you a tool decorator, you wire it to an API, you point Claude at it, and it works on the first try.
That’s the trap. The afternoon version is also the version that ships an unauthenticated callback endpoint, blocks on a 30-second command, and leaks its own credentials to the first printenv. We know because we built and run our own — the Slipstream MCP server that lets Claude run commands on remote machines — and the gap between “it works” and “we’d run this in production” was most of the work.
Here are the decisions that actually matter when you self-host an MCP server, and the pitfalls that bite after the demo.
Pick the transport deliberately, not by default
The Model Context Protocol defines two transports today: stdio and Streamable HTTP. The choice isn’t cosmetic — it decides your auth model, your deployment shape, and whether one user or a thousand can hit your server.
- stdio runs the server as a child process of the client. The client launches it, talks over stdin/stdout, and there’s exactly one user. Auth is implicit — if you can spawn the process, you’re trusted. This is the right default for a local, single-user tool.
- Streamable HTTP runs the server as a standalone service on a single HTTP endpoint that handles POST and GET, optionally streaming responses over Server-Sent Events. This is what you want for a remote, multi-client server — and it means you now own authentication, rate limiting, and tenant isolation yourself.
One currency note worth flagging, because a lot of older tutorials get it wrong: the standalone “HTTP+SSE” transport from the original 2024-11-05 spec was deprecated in the 2025-03-26 revision and replaced by Streamable HTTP. If you’re reading a guide that has you stand up a long-lived /sse endpoint as the primary transport, it’s out of date. Build on Streamable HTTP. The protocol is also moving toward a stateless model at the transport layer in upcoming revisions, which reinforces the same advice below: don’t pin user state to a connection.
Keep the server thin
The single best structural decision we made was refusing to let the MCP server do anything. It doesn’t execute commands. It doesn’t hold business logic. It’s a thin translation layer between the model and a real API:
Claude → MCP Server → Your API → execution backend
← result ←
The MCP server’s whole job is: authenticate the caller, validate inputs, call the API, format the result for the model. Everything dangerous — running the command, touching the database, mutating state — lives behind the API, where it already has its own authz, logging, and audit trail.
This matters because we treat the MCP server as the least trusted process in your stack. It’s the one running on a developer’s laptop, configured from a JSON file, talking to an LLM that will gladly pass it whatever a prompt-injected web page suggested. Keep its blast radius near zero. If someone compromises the MCP layer, they should gain nothing they couldn’t already get by calling your API directly with their own token.
Authenticate every hop — including the one you forgot
Most teams remember to authenticate the request into the server. We did too: personal API tokens (pat_-prefixed), stored as SHA-256 hashes, compared with a timing-safe equality check. Standard, and worth doing from day one.
The hop teams forget is the callback. Our command execution is asynchronous — the API hands the work to a remote agent, and the agent POSTs the result back when it’s done. In the first version, that result endpoint had no authentication. Anyone who could guess an exec ID could post a fake result, making a user believe a command succeeded when it didn’t, or vice versa. We only caught it in a structured security review.
Two fixes, both cheap if you do them first:
- Authenticate the callback. The result endpoint now verifies that the device’s API key matches the device that actually owns that exec request. A result for a job you don’t own is rejected.
- Make IDs unguessable. We had been using 8 hex characters for exec IDs — fine for a primary key, useless as a security boundary. We switched to full UUIDs. If an identifier doubles as an authorization token, it needs the entropy to match.
There’s one more hop that isn’t a token check at all: the request origin. The MCP spec explicitly tells remote Streamable HTTP servers to validate the Origin header on every incoming connection, to defend against DNS-rebinding attacks — where a malicious page in the user’s browser resolves its own hostname to your server’s address and drives it from inside the victim’s network. Reject requests whose Origin you don’t expect, and bind local servers to 127.0.0.1 rather than 0.0.0.0 so they aren’t reachable off-box in the first place. It’s a few lines of middleware and it closes a hole that authentication alone doesn’t.
The general pitfall: an MCP server is a distributed system, and every arrow in your architecture diagram is an attack surface, not just the one the user touches. Start with the review, don’t bolt it on after.
Design tools for the model, not just the API
A REST endpoint is for a programmer who reads docs. An MCP tool is for a model that reads only the tool’s metadata and guesses. That changes how you write them.
The current TypeScript SDK recommends registerTool() (the older server.tool() helper still works, but registerTool is what the docs point new code at). Validate inputs with a real schema — don’t trust the model to send well-formed arguments:
server.registerTool(
"execute_command",
{
title: "Execute command",
description:
"Execute a shell command on a remote device. Returns stdout, " +
"stderr, and exit code. Requires the exec:command permission.",
inputSchema: z.object({
device_id: z.coerce.number().describe("Target device ID"),
command: z.string().max(10_000).describe("Shell command to run"),
}),
},
async ({ device_id, command }) => {
const result = await api.exec(device_id, command); // never run it here
return { content: [{ type: "text", text: result }] };
}
);
Two things that punch above their weight:
- Descriptions are routing logic. The model decides whether and how to call your tool entirely from its description. “Run a command” gets called at the wrong times. “Execute a shell command on a remote device; returns stdout, stderr, and exit code; requires the exec:command permission” gets called correctly. Spend real effort here.
- Error messages are for the model, too. Return
"exec:command permission required — grant it under Team > Permissions", not403 Forbidden. The model relays your error to the user, so make it actionable. A good error message turns a dead end into a self-service fix.
Don’t block on long-running work
A single HTTP await is a fine mental model right up until a command takes 30 seconds. Real work — a build, a query, a restart — doesn’t return in milliseconds, and an LLM client that’s blocked is a bad experience.
We submit the command, get back an exec_id, and poll with adaptive backoff: start at 300ms (most commands finish sub-100ms and we catch them on the first poll), then back off to a 2-second ceiling so slow jobs don’t hammer the API.
let interval = 300;
while (Date.now() - start < maxWaitMs) {
const result = await apiGet(`/exec/${execId}`);
if (result.status === "completed" || result.status === "failed") return result;
await new Promise((r) => setTimeout(r, interval));
interval = Math.min(interval * 1.5, 2000);
}
The pitfall hiding here is state. The naive version keeps the pending job in a map in process memory — which works for exactly one server instance and falls apart the moment you scale horizontally or the process restarts mid-job. Keep job state in your API/datastore, keyed by ID, and let any instance resolve any job. This is also the direction the MCP spec itself is moving — toward a stateless model at the transport layer, where session affinity goes away even though your application can still hold state — so designing for it now is free insurance.
And bound everything. We cap at 60 commands per minute per device, a 30-second max execution, and a 1MB output ceiling. These don’t constrain real interactive use; they stop a runaway agent (or a bad prompt) from turning your server into a denial-of-service amplifier.
Make installation boring
If self-hosting means your users hand-edit a JSON config and install Node themselves, half of them never finish. For local stdio servers, package as an .mcpb Desktop Extension (the format formerly called .dxt, renamed to MCP Bundle in late 2025): one file, double-click to install, dependencies bundled, secrets stored in the OS keychain. The difference in adoption between “run this npx command and edit your config” and “double-click this file” is night and day. For remote servers, the equivalent is a clean token-issuance flow — let users mint a scoped token from a dashboard, never share login credentials. (Here’s what driving one of these from Claude Code actually looks like.)
The short version
Self-hosting an MCP server is genuinely easy to start and genuinely easy to get wrong in ways you won’t see until something breaks:
- Choose Streamable HTTP for remote, stdio for local — and skip deprecated SSE.
- Keep the server thin; put everything dangerous behind an API that has its own authz.
- Authenticate every hop, especially the async callback, and use high-entropy IDs.
- Write tool descriptions and errors for the model that reads them.
- Don’t block, don’t hold state in process, and bound every limit.
We learned most of this the expensive way, building the open-source server behind Slipstream. If you’d rather not hand-roll the agent, relay, and security model, try Slipstream — it’s the managed version of exactly these patterns.
Sources / last verified 2026-06-28: MCP transports and the HTTP+SSE → Streamable HTTP deprecation — MCP specification, transports (current released revision 2025-11-25); Desktop Extensions and the .dxt → .mcpb rename — Anthropic Engineering, “Claude Desktop Extensions” and the modelcontextprotocol/mcpb repo; registerTool() as the recommended API — official TypeScript SDK server docs.