DEV Community

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

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

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 (0)