Skip to main content

How to Run an MCP Server in Production

Short answer

After a server is deployed, most of the work is in what it reports and what it refuses to do. Write logs to stderr as one structured line per request, so a log collector can count errors per tool. Give every tool call a deadline, so one stuck call can't hold a client forever. Serve a health route when the server runs over HTTP, and when a tool list changes, send the spec's list-changed notification instead of renaming tools in place. The example below adds JSON logs, a per-request timer and a 30-second deadline on tool calls to a Python server, and a test checks each of them.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0MCP Inspector 2.11.0Node.js 24.19 (the Inspector needs 22.19 or later)Python 3.10.18 and 3.14.0Checked 20 Oct 2026

What you'll have at the end​

A server whose stderr gets one JSON object per request. These two lines come from the test run in step 3. The first is a tools/list call, and the second is a tool that ran past its one-second deadline:

{"ts": "2026-10-20T10:55:53.184+00:00", "level": "INFO", "logger": "ops_demo", "msg": "request finished", "method": "tools/list", "tool": null, "request_id": "0", "outcome": "ok", "duration_ms": 0.2}
{"ts": "2026-10-20T10:55:54.189+00:00", "level": "INFO", "logger": "ops_demo", "msg": "request finished", "method": "tools/call", "tool": "slow_report", "request_id": "1", "outcome": "timeout", "duration_ms": 1000.4}

The server keeps stdout for the protocol and sends everything else to stderr. Your log collector reads the lines and your alert counts the outcomes.

Before you start

  • Python: Python 3.10 or later and uv.
  • Node.js: Node.js 22.19 or later, for the MCP Inspector check in step 3.
  • The example runs over stdio, so you can test it on your own machine first. For a remote server, read the deploy guide first.

1. Set up the project​

Create a project folder, save this as pyproject.toml in it, and install the dependencies. The mcp package is the official Python SDK, and this guide uses its 2.x line.

pyproject.toml
[project]
name = "run-mcp-server-in-production"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]>=2.3.0",
]
uv sync

2. Add logs, timing and a tool deadline​

Save this as server.py next to pyproject.toml:

server.py
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone
from typing import Annotated, Any

import anyio
from mcp.server import MCPServer, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.shared.exceptions import MCPError
from mcp.types import CallToolResult, TextContent
from pydantic import Field


class JsonFormatter(logging.Formatter):
"""One JSON object per line. Extra fields come from `extra={"fields": {...}}`."""

def format(self, record: logging.LogRecord) -> str:
entry: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
timespec="milliseconds"
),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
entry.update(getattr(record, "fields", {}))
return json.dumps(entry)


# stdout carries the protocol over stdio, so every log line goes to stderr.
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(JsonFormatter())
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), handlers=[_handler])
logger = logging.getLogger("ops_demo")

TOOL_TIMEOUT_SECONDS = float(os.environ.get("TOOL_TIMEOUT_SECONDS", "30"))
# A subscription stays open for as long as the client listens, so timing it would log its lifetime.
LONG_LIVED_METHODS = frozenset({"subscriptions/listen"})

ORDERS = {
"ORD-1001": {"status": "shipped", "items": 2},
"ORD-1002": {"status": "processing", "items": 1},
}


class RequestLogMiddleware:
"""Times each request, enforces a deadline on tool calls, and logs one line per request.

Arguments are not logged. Only the method, tool name, request ID, outcome and duration are.
"""

async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
if ctx.request_id is None or ctx.method in LONG_LIVED_METHODS:
return await call_next(ctx)

tool = ctx.params.get("name") if ctx.method == "tools/call" and ctx.params else None
timeout = TOOL_TIMEOUT_SECONDS if ctx.method == "tools/call" else None
started = time.perf_counter()
outcome = "ok"
result: HandlerResult = None
try:
with anyio.move_on_after(timeout) as scope:
result = await call_next(ctx)
if scope.cancelled_caught:
outcome = "timeout"
result = CallToolResult(
content=[TextContent(type="text", text=f"Tool timed out after {TOOL_TIMEOUT_SECONDS:g} seconds")],
is_error=True,
)
elif isinstance(result, CallToolResult) and result.is_error:
outcome = "tool_error"
return result
except MCPError:
outcome = "protocol_error"
raise
except Exception:
outcome = "server_error"
raise
finally:
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
logger.info(
"request finished",
extra={
"fields": {
"method": ctx.method,
"tool": tool,
"request_id": str(ctx.request_id),
"outcome": outcome,
"duration_ms": elapsed_ms,
}
},
)


mcp = MCPServer("ops-demo", middleware=[RequestLogMiddleware()])


@mcp.tool()
def lookup_order(
order_id: Annotated[str, Field(description="Order ID in the form ORD-1234", pattern=r"^ORD-\d{4}$")],
) -> str:
"""Look up the status of an order by its ID."""
order = ORDERS.get(order_id)
if order is None:
return f"No order with ID {order_id}."
return f"{order_id}: {order['status']}, {order['items']} item(s)"


@mcp.tool()
async def slow_report(
seconds: Annotated[float, Field(ge=0, le=60, description="How long the report takes, in seconds")],
) -> str:
"""Simulate a report that takes a while to build."""
await anyio.sleep(seconds)
return f"Report ready after {seconds:g} seconds."


if __name__ == "__main__":
mcp.run(transport="stdio")

The parts that matter for production:

  • JsonFormatter turns each log record into one JSON object, and basicConfig sends it to stderr. Stdout stays free for the protocol.
  • RequestLogMiddleware is a ServerMiddleware that wraps each inbound request. It times the request, applies TOOL_TIMEOUT_SECONDS to tools/call, and writes one log line in a finally block, so errors get logged too.
  • The log line has the method, tool name, request ID, outcome and duration. It doesn't have the tool arguments, which can hold customer data.
  • subscriptions/listen is excluded from timing. A subscription stays open as long as the client listens, so its duration would only measure how long the client stayed connected.
  • MCPServer(..., middleware=[...]) adds the middleware. The SDK runs its own OpenTelemetry middleware before yours, which step 8 covers.

When a deadline passes, the tool call returns a result with isError set to true and a message that names the limit. The model can read that message, retry or tell the user, and the server keeps running.

3. Test it with MCP Inspector​

This script starts the server, lists the tools in both protocol eras, calls a tool, runs a tool past its deadline, and checks the log lines. It exits non-zero if any check fails.

test.sh
#!/usr/bin/env bash
# Checks the production example over stdio with the MCP Inspector CLI:
# tools/list in the modern (2026-07-28) and legacy (2025-era) protocol eras,
# a successful tool call, a tool that runs past its deadline, and the JSON log
# lines the server writes to stderr for each request. Exits non-zero on failure.
set -euo pipefail
cd "$(dirname "$0")"

INSPECTOR="npx -y @modelcontextprotocol/inspector@2.11.0 --cli"
SERVER=(uv run --directory "$PWD/python" server.py)
LOG="$(mktemp -t run-prod-server.log.XXXXXX)"
trap 'rm -f "$LOG"' EXIT

fail() { echo "FAIL: $1"; exit 1; }

(cd python && uv sync --quiet)

for era in modern legacy; do
out="$($INSPECTOR "${SERVER[@]}" --protocol-era "$era" --method tools/list 2>>"$LOG")"
echo "$out" | grep -q '"lookup_order"' || fail "tools/list ($era) is missing lookup_order"
echo "$out" | grep -q '"slow_report"' || fail "tools/list ($era) is missing slow_report"
echo "ok: tools/list ($era)"
done

out="$($INSPECTOR "${SERVER[@]}" --protocol-era modern --method tools/call \
--tool-name lookup_order --tool-arg order_id=ORD-1001 2>>"$LOG")"
echo "$out"
echo "$out" | grep -q 'ORD-1001: shipped, 2 item(s)' || fail "lookup_order returned the wrong text"
echo "ok: tools/call lookup_order"

# The tool runs for 5 seconds and the deadline is 1 second. The server must
# return an error result instead of waiting. The Inspector exits 5 on an error
# result, so the exit code is ignored here and the output is checked instead.
out="$($INSPECTOR -e TOOL_TIMEOUT_SECONDS=1 "${SERVER[@]}" --protocol-era modern --method tools/call \
--tool-name slow_report --tool-arg seconds=5 2>>"$LOG" || true)"
echo "$out"
echo "$out" | grep -q 'Tool timed out after 1 seconds' || fail "slow_report did not hit the deadline"
echo "$out" | grep -q '"isError": true' || fail "slow_report result is not marked isError"
echo "ok: tool deadline returns an error result"

grep -q '"tool": "lookup_order".*"outcome": "ok"' "$LOG" || fail "no structured log line for lookup_order"
grep -q '"tool": "slow_report".*"outcome": "timeout"' "$LOG" || fail "no structured log line for the timeout"
python3 - "$LOG" <<'PY'
import json
import sys

timed = None
with open(sys.argv[1], encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line.startswith("{"):
continue
entry = json.loads(line)
if entry.get("tool") == "slow_report" and entry.get("outcome") == "timeout":
timed = entry
if timed is None:
sys.exit("FAIL: slow_report timeout line is not valid JSON with outcome timeout")
if not 900 <= timed["duration_ms"] <= 3000:
sys.exit(f"FAIL: slow_report duration_ms {timed['duration_ms']} is outside the 1 second deadline")
print(f"ok: log line is JSON, duration_ms={timed['duration_ms']}")
PY

echo "All checks passed"

From the example folder, run:

bash test.sh

It ends with All checks passed. To run the deadline check by hand, set the limit with -e and pass the tool arguments as the Inspector expects:

npx @modelcontextprotocol/inspector@2.11.0 --cli -e TOOL_TIMEOUT_SECONDS=1 \
uv run --directory "$PWD/python" server.py --protocol-era modern \
--method tools/call --tool-name slow_report --tool-arg seconds=5

The --protocol-era flag picks the protocol era the Inspector uses. modern is the 2026-07-28 revision, and legacy is the 2025-era initialize handshake that older clients still use.

4. Add health checks​

How you check health depends on the transport.

  • Over stdio there is no port to probe. The 2026-07-28 spec removes ping and requires every server to implement server/discover. A supervisor can send server/discover and treat a valid answer as healthy. When we ran the Inspector in modern mode, its first request was server/discover, and the log line shows it.
  • Over Streamable HTTP the MCP endpoint takes POST requests, so give load balancers a plain route. The Python SDK provides @mcp.custom_route("/health") for that, and the deploy guide shows it in use.

Keep the health route cheap. It should check that the process is up and, if you need it, that its dependencies answer. It shouldn't call a tool.

5. Set timeouts and handle cancellation​

The spec says implementations SHOULD set timeouts for every request they send, and SDKs and middleware SHOULD let you set them per request. A client that receives progress notifications may reset its clock, but the spec says a maximum should still apply. That covers the client side. On the server, the deadline in step 2 is yours to choose.

A client cancels a request in one of two ways. Over Streamable HTTP, it closes the response stream, and the server MUST treat that disconnect as cancellation. Over stdio, it sends notifications/cancelled with the request ID. The server SHOULD then stop processing, free its resources and not send a response.

A deadline can only stop code that awaits. A blocking call inside a tool keeps running after the deadline, because Python can't interrupt it from outside. Use async I/O with its own timeout for calls that leave your process, and keep long CPU work out of the request path.

6. Rate limit tool calls​

The spec says servers MUST rate limit tool invocations. It also says servers SHOULD rate limit log messages, and clients and servers SHOULD rate limit progress notifications. The mcp 2.3.0 package has no rate limiter of its own. We searched the installed package and found none, so the limit is yours to write.

Put it where the caller is known. Over HTTP, use the identity from your authorization layer and count calls per caller in middleware, or set the limit at your gateway. The spec says clients identify themselves in clientInfo, but it also says not to rely on that field for security decisions. Over stdio, one local user runs the server, so the limit matters less.

7. Change tools without breaking clients​

Adding a tool is usually safe. Renaming or removing one isn't. A prompt or client setting that calls the old name fails, and a parameter that becomes required breaks every call that still sends the old shape. The tool naming guide covers names in more depth. Add the new name, keep the old one until clients have moved, and then remove it. Add new parameters as optional, with defaults.

When the list does change, tell the clients that are listening:

  • Servers SHOULD send notifications/tools/list_changed to each client that opened a subscriptions/listen stream with toolsListChanged: true. The notification tells the client the list changed, so it knows to request tools/list again.
  • Servers SHOULD return tools in a deterministic order, so clients can cache the list and prompt caches stay warm.
  • The 2026-07-28 changelog requires ttlMs and cacheScope on tools/list results. The Python SDK fills them from the cache_hints argument to MCPServer, so set a hint there instead of building results by hand.

The spec says servers should send the notification, but it doesn't say how clients must react to it. Test the change in each client your users run. The example server's tool list never changes, so it doesn't send the notification.

8. Trace tool calls with OpenTelemetry​

The 2026-07-28 spec reserves traceparent, tracestate and baggage in a request's _meta for OpenTelemetry trace context. Their values use the W3C Trace Context and W3C Baggage formats, so a trace started by the client can continue into your server.

The Python SDK already calls OpenTelemetry. Its default middleware creates a span for each inbound message, and it does nothing until you install an OpenTelemetry SDK and an exporter. The comment in the SDK source says so. We checked it with a throwaway script that added opentelemetry-sdk and a console exporter. Each request produced a span, and a tool call appeared as a span named for the method and the tool, such as tools/call lookup_order.

Set up the OpenTelemetry SDK and an exporter for your backend before the server starts. Keep the JSON log lines too. The spec deprecates the protocol's own logging feature and recommends stderr for stdio servers or OpenTelemetry for observability. Use logs for events and spans for timings.

9. Alert on tool error rates​

Each log line carries tool, outcome and duration_ms. The outcomes in this example are:

OutcomeWhat it means
okThe request finished with a normal result
tool_errorThe tool returned isError: true, such as bad input the model can correct
timeoutThe tool passed its deadline
protocol_errorThe request failed with an MCPError, the SDK's protocol error type
server_errorAn unexpected exception escaped the request

Alert on the share of non-ok outcomes for each tool over a window, and on any rise in timeout or server_error. The SDK logs unexpected exceptions with a traceback, so the log line and its traceback can be read together. A rise in tool_error usually points at a tool description the model misreads or an upstream change. A rise in timeout or server_error usually points at your code or your dependencies.

Common problems​

The Inspector exits with code 5 and prints "code":"tool_is_error". The output looks like this:

{"error":{"code":"tool_is_error","message":"Tool 'slow_report' returned isError:true."}}

The tool returned a tool error, and the Inspector treats that as a failed command. The server did what it should. The test script in step 3 accepts that exit code and checks the output instead.

The client disconnects right after the server starts. Something is writing to stdout. Find the print() call or the library that writes to stdout, and send that output to stderr through logging. Stdout carries the protocol, so any other text breaks it.

FAQ​

Should an MCP server log to stdout or stderr?

Use stderr for a stdio server. Stdout carries the protocol messages, so anything else written there can break the connection. Log to stderr with logging in Python.

Does the MCP protocol have its own logging?

It did. The 2026-07-28 spec deprecates the logging feature, and it recommends stderr for stdio servers or OpenTelemetry for structured observability. New servers shouldn't build on the protocol's logging.

How long should a tool call be allowed to run?

Pick a limit above the slowest normal run of the tool, then test it. The example uses 30 seconds. Clients set their own timeouts too, and the spec leaves those to each client.

Do I need a health check for a stdio server?

There is no port to probe. Send server/discover from your supervisor and treat a valid answer as healthy. Use an HTTP route only when the server runs over Streamable HTTP.