Writing an MCP server is the fast part. A basic server that exposes a tool or two, tested against Claude Desktop or a local client, can come together in an afternoon. What takes longer, and what most getting-started guides skip entirely, is what happens once that server is actually running somewhere a client depends on it every day. You need to know when a tool call failed and why, you need logging that does not break the protocol it runs over, and you need to handle the fact that a client can disconnect mid-call without warning. This article covers what changed for me moving an MCP server from a local script to something running in production.

The first problem: you cannot just print to stdout

If your MCP server talks to its client over stdio, which is the most common transport for local servers, stdout is the protocol channel. Every line written to stdout is expected to be a JSON-RPC message. If your logging code, or a library you depend on, writes a plain text line to stdout for debugging, you have just sent garbage into the protocol stream, and the client will either error out or silently drop the message.

The fix is simple once you know to look for it: send all logs to stderr instead.

import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    stream=sys.stderr,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

logger = logging.getLogger("mcp.server")

This catches your own logging calls, but it does not catch a dependency that calls print() directly, which happens more often than you would expect from libraries that were never designed to run inside something like an MCP server. Test this deliberately: run your server, trigger every code path you can, and check stdout is carrying nothing but valid JSON-RPC. A single stray print statement three dependencies deep will cost you an afternoon of confused debugging the first time a client mysteriously stops receiving responses.

Returning errors the client can actually use

A tool call inside your server can fail for reasons that have nothing to do with your code: a downstream API is down, a file does not exist, a database query times out. The MCP specification supports returning a proper error response for a failed tool call rather than letting an unhandled exception crash the whole server process. Catch the failure at the tool boundary and return a structured error instead:

from mcp.server import Server
from mcp.types import TextContent

server = Server("my-mcp-server")

@server.call_tool()
async def handle_tool_call(name: str, arguments: dict):
    try:
        if name == "fetch_report":
            result = await fetch_report(arguments["report_id"])
            return [TextContent(type="text", text=result)]
        raise ValueError(f"Unknown tool: {name}")

    except TimeoutError:
        logger.warning(f"Tool {name} timed out with arguments {arguments}")
        return [TextContent(
            type="text",
            text="The request timed out. The upstream service may be slow or unavailable right now.",
        )]

    except Exception as exc:
        logger.exception(f"Tool {name} failed")
        return [TextContent(
            type="text",
            text=f"The tool call failed: {exc}",
        )]

The important detail here is that one failed tool call should never take down the server process for every other client connected to it. If your server handles multiple sessions, an unhandled exception in one tool call can crash the whole process depending on how your transport layer is set up, which turns a single bad request into an outage for every other client using the same server instance.

Handling a client that disconnects mid-call

A client can close its connection while your server is still in the middle of a tool call, especially for anything that takes more than a second or two, like a network request or a long-running query. If your tool implementation does not handle cancellation, you end up with orphaned work still running against a connection nobody is listening to anymore.

import asyncio

@server.call_tool()
async def handle_tool_call(name: str, arguments: dict):
    try:
        result = await asyncio.wait_for(
            run_long_task(arguments),
            timeout=30,
        )
        return [TextContent(type="text", text=result)]
    except asyncio.CancelledError:
        logger.info(f"Tool {name} was cancelled, client likely disconnected")
        raise
    except asyncio.TimeoutError:
        return [TextContent(type="text", text="The operation timed out after 30 seconds.")]

Re-raising CancelledError instead of swallowing it matters. If you catch it and try to continue, you fight against the event loop’s own cancellation machinery, which usually causes stranger bugs than the disconnect itself would have.

Observability: knowing what your server actually did

Once your MCP server is doing real work, you want the same visibility you would expect from any backend service: which tools are called, how often, how long they take, and what fraction fail. If you already run OpenTelemetry, wrap each tool call in a span using the MCP semantic conventions, which give you dedicated fields for exactly this layer.

from opentelemetry import trace

tracer = trace.get_tracer("mcp.server")

@server.call_tool()
async def handle_tool_call(name: str, arguments: dict):
    with tracer.start_as_current_span(f"tools/call {name}") as span:
        span.set_attribute("mcp.method.name", "tools/call")
        span.set_attribute("gen_ai.tool.name", name)

        try:
            result = await dispatch_tool(name, arguments)
            span.set_attribute("mcp.result.status", "success")
            return result
        except Exception as exc:
            span.set_attribute("mcp.result.status", "error")
            span.set_attribute("error.message", str(exc))
            raise

Even without a full tracing backend, structured logs with the same fields get you most of the way there. The specific thing to track, regardless of tooling, is per-tool call volume and per-tool error rate. In practice, one tool usually accounts for most of the traffic and most of the failures, and you will not know which one until you can actually see the breakdown.

Validating input before it reaches your business logic

The arguments a client sends to a tool call come from whatever the calling model decided to generate, and a model can generate malformed, missing, or unexpected arguments even when your tool schema is well defined. Do not assume the arguments dict matches your schema just because you declared one. Validate it explicitly before it touches anything that matters:

def validate_fetch_report_args(arguments: dict) -> str:
    report_id = arguments.get("report_id")
    if not report_id or not isinstance(report_id, str):
        raise ValueError("report_id is required and must be a string")
    if not report_id.isalnum():
        raise ValueError("report_id must be alphanumeric")
    return report_id

This matters more than it looks like it should, especially for any tool that touches a filesystem path, a database query, or a shell command. A model hallucinating a slightly wrong argument is a normal, expected occurrence, not an edge case, and your server needs to reject it cleanly rather than pass it straight into something that trusts it.

A short production checklist

  • Send all logging to stderr, never stdout, if your server runs over the stdio transport, and check every dependency for stray print statements
  • Catch exceptions at the tool call boundary and return a structured error response instead of letting one failure take down the whole server process
  • Set timeouts on anything that can hang, and handle cancellation properly when a client disconnects mid-call
  • Track per-tool call volume and error rate, either through OpenTelemetry spans or structured logs, so you know which tool is actually causing problems
  • Validate tool arguments explicitly rather than trusting that a model-generated call matches your declared schema

Frequently Asked Questions

Why should MCP server logs go to stderr instead of stdout?

When an MCP server uses the stdio transport, stdout carries the JSON-RPC protocol messages between the server and the client. Any plain text written to stdout, including a debug print statement, corrupts that stream and can cause the client to error out or silently drop messages. Logging should always go to stderr instead.

What happens if a tool call in an MCP server throws an unhandled exception?

Depending on how the transport and process are set up, an unhandled exception in one tool call can crash the entire server process, affecting every other client connected to it. Tool calls should catch exceptions at their boundary and return a structured error response instead of letting the exception propagate uncaught.

How do I monitor an MCP server in production?

Track per-tool call volume and error rate, either with OpenTelemetry spans using the MCP semantic conventions or with structured logs carrying the same fields. In practice, a small number of tools usually account for most of the traffic and most of the failures, and that breakdown is only visible once you are actually recording it.

Should I trust tool call arguments sent by the model?

No. Arguments come from whatever the calling model generated, and a model can produce malformed, missing, or unexpected values even against a well defined tool schema. Validate arguments explicitly before they reach any code that touches a filesystem, database, or shell command.

You can also read this article on Medium.

Related Articles:

OpenTelemetry Now Traces MCP Tool Calls

Instrumenting an AI Agent with OpenTelemetry GenAI Conventions


Discover more from Tech Insights & Blogs by Rahul Ranjan

Subscribe to get the latest posts sent to your email.

Leave a Reply

Trending

Discover more from Tech Insights & Blogs by Rahul Ranjan

Subscribe now to keep reading and get access to the full archive.

Continue reading