DEV Community

Cover image for Notes on running an MCP server in production
Léa Moreau
Léa Moreau

Posted on • Edited on

Notes on running an MCP server in production

Most MCP tutorials stop at a single stdio tool. These notes cover what I had to change once a server of mine had actual users, in the order the problems appeared.

Logging corrupts the protocol if you let it

The stdio transport uses stdout for JSON-RPC frames. A single console.log, including one buried in a dependency, breaks the stream. The symptom is a client that disconnects for no visible reason, and nothing in the error output points back to the cause.

The fix is unglamorous. Every log line goes to stderr through a small structured wrapper, and that entire class of bug disappears.

Plan for the second transport early

Editor clients (Claude Desktop, Claude Code, Cursor) talk stdio to a local process. Hosting the same server for a team requires Streamable HTTP, and the refactor is painful if the code assumes one global server instance.

I build everything behind a factory now:

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;
}
Enter fullscreen mode Exit fullscreen mode

The stdio entrypoint calls it once at startup. The HTTP handler calls it per request and keeps no session state. Request isolation comes from the structure rather than from discipline, and horizontal scaling requires no coordination between replicas.

Validate configuration at boot

A malformed environment variable should stop the process immediately with a readable message, instead of surfacing hours later as strange behavior. I parse all config with Zod at startup.

Tool inputs get the same treatment. Declare them as Zod schemas and the SDK validates every call before your handler runs, so handlers only ever receive typed, checked arguments.

Treat a fetch tool as a proxy

A tool that fetches URLs is an HTTP proxy driven by a language model, and the model reads untrusted text all day. Mine enforces four limits: http and https only, an optional hostname allowlist, a byte cap on responses, and a timeout. Without the allowlist, a prompted model can probe your internal network from inside your infrastructure. This is a standard SSRF vector.

Test against the real server, in memory

The SDK provides InMemoryTransport.createLinkedPair(). A real client connects to the real server inside the test process, with no sockets and no subprocess management. My suite exercises tools, resources and prompts this way and completes in a few seconds on Node 20, 22 and 24.

Update (July 11): @skillselion points out in the comments that the in-memory pair structurally cannot catch stdout corruption, the exact bug this post opens with, because it bypasses the real stdio framing. He is right. The template now ships a subprocess smoke test next to the in-memory suite: it spawns the actual entrypoint with the SDK's StdioClientTransport and completes an initialize plus tools/list exchange over a real pipe, so anything that pollutes stdout fails CI instead of disconnecting a user.

His other two points are covered too. The suite now asserts the serialized tools/list payload stays under a size budget, since every rich .describe() string and nested Zod object ends up in what each client downloads at session start. And a raw JSON-RPC test confirms the server correctly echoes back an older supported protocol version when a client requests one, instead of forcing the latest, which matters for long-lived editor clients that pin older revisions.

The template

I assembled these pieces into a template I now start every server from: both transports, the guarded fetch tool, the test setup, Docker and CI. It costs $19 and lives on my Gumroad page. The notes above contain most of the design, so building your own from them is a perfectly reasonable choice too.

If production has bitten you somewhere I did not list, leave a comment. I am collecting these.

Top comments (4)

Collapse
 
skillselion profile image
Skillselion

One gap in the testing section, and it is your own first bug that exposes it: the InMemoryTransport pair can never catch stdout corruption. The linked pair bypasses the actual stdio framing, so a console.log buried in a dependency passes your entire suite and still kills real clients. The in-memory suite is the right default for tool logic, but it needs one subprocess smoke test next to it - spawn the real binary, speak one initialize/tools-list exchange over actual stdio, assert clean frames. That single test is the only thing standing between you and the exact disconnect-with-no-error you opened with.

Two more production line items I would add: tools/list payload size (every connected client pays your full tool descriptions at session start, and generated JSON schemas from rich Zod objects get big fast), and protocol version skew - long-lived editor clients pin older protocol revisions while your HTTP path negotiates the latest, so the factory needs testing against both.

Collapse
 
leamoreau profile image
Léa Moreau

You are right, and the smoke test point is the one that stings: the InMemoryTransport pair wires two message streams together directly, so the newline-delimited framing on stdout is never exercised. A dependency that prints to stdout passes the whole suite and still kills a real client.

I have added exactly what you describe to the template's suite: spawn the real entrypoint with the SDK's StdioClientTransport, complete an initialize plus tools/list exchange over an actual pipe, and assert on the listing. Anything that pollutes stdout now fails the handshake in CI instead of disconnecting a user. I also added an update to the post crediting you.

Your other two points are in now as well. A test asserts the serialized tools/list payload stays under a size budget, since every rich .describe() string and nested Zod object ends up in what each client downloads at session start. And a raw JSON-RPC test pins an older protocol version on the initialize request and confirms the factory echoes it back correctly instead of forcing latest, which is the failure shape you described for long-lived editor clients.

Thanks!

Collapse
 
skillselion profile image
Skillselion

The size-budget test is the one I expect to age best - schema weight creeps in through innocent .describe() edits that no reviewer flags, and a hard assertion is the only thing that notices. One refinement as the server grows: budget per tool rather than one total, so a new tool cannot silently eat the headroom the existing ones were counting on. The version-pinning test landing in CI instead of in a user's editor is exactly the right failure relocation. Glad the points were useful - and thanks for the credit.

Thread Thread
 
leamoreau profile image
Léa Moreau

Implemented. The suite now asserts a per-tool byte budget (700 bytes) alongside the existing aggregate check, so a tool that grows past its own share fails on its own assertion line instead of waiting for the aggregate to eventually tip over. The three shipped tools sit between 341 and 416 bytes each right now, so there's real headroom before either budget needs raising deliberately. 13/13 tests pass, zip re-shipped with the fix.
Second round of feedback that landed directly in the product. Thanks for following through :)