A Checklist for Shipping a Reliable MCP Server
Most MCP servers fail quietly. The process stays up, the port stays open, and clients still get worse results — a schema that drifted, a timeout that's too aggressive, a tool description too vague for the model calling it to use correctly. None of that trips a normal "is it down" alert. Here's a working checklist for the parts of reliability that don't show up in a process monitor.
Transport and handshake
- Pick the right transport for the job. stdio for local, single-user tools launched by the client; Streamable HTTP for anything remote or multi-client. Don't reach for HTTP just because it's more familiar — stdio avoids a whole category of auth and network-exposure problems if you don't need remote access.
- Get session handling right on Streamable HTTP. If your server issues an
MCP-Session-Id, honor it consistently: reject requests missing it with400, return404once a session has expired, and don't silently create a new session when an old ID goes stale — that's a common source of confusing client-side bugs. - Validate
Originand bind to localhost for local servers. This is a security requirement, not a nice-to-have — it's the specific mitigation for DNS-rebinding attacks against locally running MCP servers. - Respect stdout discipline on stdio servers. Nothing but valid JSON-RPC messages goes to stdout, ever. Route all logging to stderr. A single stray
print()breaks every client parsing that stream. - Set a real timeout budget for
initialize. If your server does slow setup — auth lookups, connection pooling, cold starts — that cost shows up as intermittent client-side timeout failures long before anyone thinks to check startup latency.
Tool schema hygiene
- Treat
inputSchemaandoutputSchemaas a public API. They're JSON Schema, and clients build against them exactly like clients build against a REST contract. Breaking changes are: renaming a field, adding torequired, narrowing a type, or removing an enum value. Adding an optional field is safe. - Write descriptions for the model, not for other engineers. A tool's
descriptionis the primary signal an LLM uses to decide when and how to call it. Vague descriptions produce wrong tool calls, not just confused humans reading your docs. - Snapshot-test the schema in CI. A committed golden file for
tools/list, diffed on every PR, forces schema changes to be reviewed deliberately instead of shipping as a side effect of a refactor. - Don't silently rename tools. Clients often cache tool names in prompts or code. Deprecate with a grace period, or keep an alias, rather than a hard cutover.
Error handling and timeouts
- Return structured tool errors, not transport errors, for expected failure cases. A tool that can fail due to bad input or an unreachable downstream should communicate that as a normal tool result the model can reason about, not as a JSON-RPC transport-level error every time.
- Set per-tool timeouts that match reality. A tool wrapping a slow external API needs a timeout budget that reflects that, communicated clearly enough that clients don't just hang.
- Make retries safe. If a client can reasonably retry a tool call (network blip, timeout), make sure the tool is idempotent or clearly documented as not being so.
Versioning and compatibility
- Negotiate and honor
protocolVersionexplicitly rather than assuming every client speaks the latest revision. - Keep a changelog for your tool schemas, separate from your general release notes — it's the artifact people actually check when a client starts behaving unexpectedly.
- Test against the protocol version you claim to support, not just the SDK version you happen to be using. SDKs move faster than the spec they wrap.
Observability
- Log the handshake, not just tool calls.
initializefailures and slowtools/listresponses are early signals of a bad deploy, and they're invisible if your logging only starts once a tool is actually invoked. - Track tool-call latency and error rate per tool, not just in aggregate. A single slow or failing tool buried in an aggregate metric can go unnoticed for a long time.
- Alert on schema changes, not just downtime. A server that's up but silently serving a different tool contract than yesterday is a production incident, even though nothing crashed.
Security basics
- Never trust
stderrcontent as a control signal, and never let a tool's error message leak internal details (stack traces, credentials, internal hostnames) into a response the model might repeat back to a user. - Scope credentials narrowly. If your MCP server proxies to internal APIs, the server's own credentials should carry the minimum privilege the tools actually need.
- Rate-limit and authenticate remote servers. stdio gets access control for free from the OS; Streamable HTTP does not, and the spec explicitly puts the burden of proper authentication on the server.
Monitoring in production
Everything above gets validated once, in development or CI. None of it tells you the server is still behaving the same way a week later, after a dependency update, a config change, or a rollback nobody flagged. That's the ongoing check, and it's the one most teams skip because it's tedious to build by hand: a scheduled initialize → tools/list handshake against the live server, with alerting on both failures and schema drift. MCP Vitals runs exactly that against remote MCP servers and posts a public status badge, which is a reasonable way to close the loop without maintaining your own polling infrastructure.
The short version
If you only do five things: get the handshake and transport basics right, treat your tool schemas as a versioned contract, test that contract in CI, keep tool descriptions written for the model rather than for humans, and put continuous monitoring on whatever's actually reachable in production. Everything else on this list is a refinement of those five.