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

> Source: <https://dev.to/leamoreau/i-built-a-production-ready-mcp-server-template-so-you-dont-have-to-heres-the-architecture-2c6i>
> Published: 2026-07-10 10:22:54+00:00

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.

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:

``` js
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.

`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.

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.

`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.

`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).

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](https://leamoreau1988.gumroad.com/l/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.
