Building your first Model Context Protocol server takes about twenty minutes. The official quickstart is genuinely good: npm install, register a tool, connect a stdio transport, and Claude or Cursor can call your code. Hello, world.
Then you try to make it something other people can actually use, and you fall off a cliff.
I know the cliff is real because someone measured it. An April 2026 scan of 2,181 remote MCP endpoints found 52% of them completely dead, and only about 9% fully healthy. These aren't abandoned toys — they're servers people shipped and expected to work. They didn't die from protocol bugs. The protocol is the easy part. They died from everything around the protocol, which is exactly what the tutorials skip.
Here are the parts that actually matter, and how I handle each. There's a small MIT-licensed starter kit at the bottom that ships all of it, but the ideas are portable to any stack.
1. On stdio, stdout is not yours
The first one bites everybody. On the stdio transport, stdout is the JSON-RPC channel. A single stray console.log — yours, or a dependency's — injects a line into the protocol stream, and the client dies with a cryptic JSON parse error that points nowhere near the actual console.log.
The fix is one word: log to stderr.
// stdout is the protocol channel on stdio. Log to stderr ONLY.
console.error(`${SERVER_INFO.name} v${SERVER_INFO.version} ready on stdio`);
Enter fullscreen mode Exit fullscreen mode
That's it. But you have to know it, and no quickstart tells you, because the quickstart never logs anything.
2. Failures have to be legible, or your agent is flying blind
When a tool throws, the default experience is terrible: the agent sees internal error and cannot tell an auth failure from a bad argument from an upstream 500. It can't decide whether to retry, fix its input, or give up — so it often retries a doomed call in a loop and burns your API budget overnight. That "quiet retry" is one of the most-cited ways agents rack up cost in production.
The fix is a typed error that carries a machine-readable code, and a wrapper that turns a throw into a proper MCP error result instead of a dropped connection:
export class ToolError extends Error {
constructor(public readonly code: ToolErrorCode, message: string) {
super(message);
}
}
// every handler funnels through this:
export function toToolResult(err: unknown) {
const e = err instanceof ToolError ? err : new ToolError("internal", String(err));
return { isError: true as const, content: [{ type: "text", text: `[${e.code}] ${e.message}` }] };
}
Enter fullscreen mode Exit fullscreen mode
Now the agent sees [forbidden_host] Host "x" is not in ALLOWED_FETCH_HOSTS and can actually reason about it. Legibility is a feature you build for the model, not just the human reading logs.
3. Any tool that fetches a URL is an open proxy until you say otherwise
The moment you write a tool that takes a URL and fetches it, you've built a potential SSRF proxy: an agent (or a prompt-injected one) can point it at http://169.254.169.254/ or your internal admin panel and read the response. A fetch tool without a host allowlist is a security incident waiting for a trigger.
So the example fetch tool refuses anything it wasn't told to allow, plus enforces https and a hard timeout:
if (parsed.protocol !== "https:") throw new ToolError("invalid_input", "https only");
if (!allowedHosts().includes(parsed.hostname))
throw new ToolError("forbidden_host", `${parsed.hostname} is not allowlisted (SSRF guard).`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout); // no unbounded hangs
Enter fullscreen mode Exit fullscreen mode
Three guards, none of which the "here's a tool that calls an API" tutorial includes.
4. Auth is the wall most remote servers never get over
Survey data from 2026 is blunt: OAuth is the single biggest blocker for production MCP servers, over half of remote servers fall back to static keys, and OAuth failures tend to be silent — the hardest kind to debug.
You don't need full OAuth to get remote-safe. You need auth that fails closed and tells you why. Bearer tokens, done properly, are the right first step:
if (tokens.length === 0) {
// An HTTP MCP server with auth off is the default that gets scraped. Refuse to run open.
return reject("No MCP_BEARER_TOKENS configured; refusing all requests.");
}
// ...constant-time compare, real 401 + WWW-Authenticate header, never log the token.
Enter fullscreen mode Exit fullscreen mode
The important word is closed. If you forget to configure tokens, the server rejects everything rather than quietly serving your tools to the internet. Full delegated OAuth 2.1 — per-user scopes, token exchange, refresh — is a real, larger job for public multi-tenant servers; the mistake is pretending a hand-rolled flow is that, or shipping with auth off "for now."
5. Statelessness is the cold-start fix
Here's the subtle one behind a lot of those 52%-dead endpoints. MCP's streamable HTTP transport can hold session state in process memory. Deploy that to anything that scales to zero or spreads load across instances — Lambda, Cloud Run, Fly, Workers — and the follow-up request lands on a cold instance that never saw the session. The client hangs. No error, no log, just dead.
The cheap fix is to not hold session state at all: build a fresh server and transport per request.
app.post("/mcp", bearerAuth, async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // stateless
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
Enter fullscreen mode Exit fullscreen mode
If you genuinely need cross-request state later, back it with Redis or a durable object keyed by session id — never a module variable. But start stateless. It's the shape that survives the platform you'll actually deploy on.
6. Test the failure paths, or you haven't tested
The last habit is the cheapest: when people do write tests for MCP tools, they test the demo — call the tool, assert the happy result. But every section above describes a guard, and a guard you haven't tested is a guard you don't have. The tests that earn their keep assert that the server refuses correctly:
it("rejects a host that is not allowlisted (SSRF guard)", async () => {
await expect(
httpGetJson({ url: "https://evil.example.com/x" }),
).rejects.toMatchObject({ code: "forbidden_host" });
});
it("rejects non-https URLs", async () => {
await expect(
httpGetJson({ url: "http://api.github.com/x" }),
).rejects.toMatchObject({ code: "invalid_input" });
});
Enter fullscreen mode Exit fullscreen mode
Notice what's being asserted: not just that it throws, but that it throws with the right machine-readable code — because that code is the contract from section 2, the thing the agent reasons about. If a refactor ever turns forbidden_host into a generic internal, the type checker won't care, the happy-path test won't care, and your agent quietly loses the ability to tell "blocked by policy" from "server bug." This test is the only thing standing there.
Same principle for auth: the test worth writing is the one where no token is configured and the server refuses everything. Fail-closed is a behavior; pin it.
The pattern
None of this is hard once you've named it. That's the whole point: the failure mode isn't difficulty, it's invisibility — every one of these is missing from the tutorials, so everyone rediscovers them the same way, in production, from a hanging client.
I packaged all six into a small, MIT-licensed TypeScript starter — stdio + streamable HTTP, the fail-closed bearer auth, the legible ToolError, the SSRF-guarded example tool, the refusal tests, and a DEPLOYMENT.md that walks the cold-start problem. It builds, it's tested, and it's meant to be read top to bottom in one sitting:
→ https://github.com/park11innyc-lgtm/mcp-server-starter-kit
Clone it, delete what you don't need, and skip the cliff.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.