Dev.to WebDev 🛠 Dev 👁 1 📖 2 min read

I built a production-ready MCP server template so you don't have to — here's the architecture

The Model Context Protocol has a "hello world" problem. Every tutorial shows you the same thing: one little stdio tool, a happy console log, voilà. Then you decide to run a server for real — for your team, for a product,

The Model Context Protocol has a "hello world" problem. Every tutorial shows you the same thing: one little stdio tool, a happy console log, voilà. Then you decide to run a server for real — for your team, for a product, for anything with stakes — and you discover that the distance between the tutorial and production is… all of the actual work.

I've built enough MCP servers now that I got tired of re-plumbing the same parts every time. So I standardised them into a template. Here's the architecture — take it and build it yourself if you want, it fits in six ideas.

1. A server factory, not a singleton

This is the one decision that makes everything else fall into place. The HTTP transport in stateless mode wants a fresh server per request, so isolation between callers is structural instead of hopeful. So the core of the codebase is one function:

export function createServer(config: Config): McpServer {
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
  registerTools(server, config, log);
  registerResources(server);
  registerPrompts(server);
  return server;
}

stdio calls it once at startup. HTTP calls it per request. Same code, both transports.

2. Two transports, one env var

TRANSPORT=stdio for editor clients (Claude Desktop, Claude Code, Cursor). TRANSPORT=http and the same build becomes a remote Streamable HTTP server behind Express — stateless, so horizontal scaling is boring, which is the best thing scaling can be.

3. Validate at the edges, crash readably

All config goes through Zod at startup. A bad env var should be a clear message at boot, not a mystery at 2am. Tool inputs are Zod schemas too — the SDK validates every call for you, so handlers receive typed, already-safe arguments. Free correctness, take it.

4. Your network tool is not a fetch() in a trenchcoat

The moment you give a model a URL-fetching tool, you've built a proxy that an LLM drives. So the template's fetch_url enforces: http/https only, an optional hostname allowlist (SSRF is not theoretical), a byte cap so a 2GB response can't eat your process, and a timeout. That's the difference between a demo and something you can expose without holding your breath.

5. Never, ever console.log on stdio

stdout is the JSON-RPC channel. One stray log line and the protocol stream is corrupted — and the resulting bug report will not mention that, it will say "your server randomly disconnects". All logging goes to stderr, structured. This rule has no exceptions and everyone learns it the same way (the bad way).

6. Test with the in-memory client

The SDK ships InMemoryTransport.createLinkedPair() — you connect a real Client to your real server, no sockets, no processes, and assert on listTools / callTool results. Fast, deterministic, perfect for CI. My suite drives the actual server this way; the whole thing runs in seconds on Node 20/22/24.

That's the architecture. If you'd rather start from the finished version — both transports wired, the guarded tools, the test suite, Docker, CI, plus a Stripe appendix for making tools paid — I packaged it as the MCP Quickstart Kit. Either way, the six ideas above are the whole design.

What patterns are you using in your MCP servers? I'm genuinely curious what I've missed — tell me in the comments and I'll answer.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.