HorizonLux · AI automation

Ship AI agents that reach production

Only about 5% of enterprise agents ever go live. We build the evaluation, routing and cost control that get yours there.

Book a free scoping call
Guides

How to Build an MCP Server That Survives Day Two

Every quickstart gets you a working MCP server in twenty minutes. None of them cover the stdout trap, error handling, testing, or auth. This one does.

A stdio pipe carrying clean JSON-RPC frames, interrupted by a stray print() statement breaking the stream

TL;DR

Building a working MCP server takes about twenty minutes: install the Python SDK, define a tool with the @mcp.tool() decorator, and run it over stdio. Every official quickstart, Anthropic's and OpenAI's included, stops there. What they skip is the part that decides whether the server survives contact with a real client: never writing to stdout, catching tool errors instead of crashing the process, testing with the Inspector before a model ever calls it, and deciding how requests get authenticated.

This is the production edition. Same starting point as the quickstart, six more steps past it.

Running MCP servers that expose real systems to an AI, not toy tools? We build and operate AI agents on infrastructure we run day two, day thirty, and day three hundred, not just the demo.

Key Takeaways

  • The official quickstarts get you a working tool in one file. They do not cover error handling, logging, testing, or auth, the parts that break in week two, not minute twenty.
  • print() in a stdio server corrupts the JSON-RPC stream. It is the single most common way a first MCP server breaks, and no default warns you.
  • A tool that raises an uncaught exception kills the server process, not just that one call, taking every other tool down with it.
  • The MCP Inspector lets you call every tool with valid and invalid input before a client ever connects. Skipping it means your first real user is the test.
  • Tool schemas are a contract. Renaming a parameter without versioning breaks every client that already integrated against it.

What an MCP server actually is

An MCP server is a program that exposes tools, resources, or prompts to an AI client over a standard protocol, so the client can discover what is available and call it without a custom integration per pair. The series hub covers why that standard exists and when a plain API is the better call instead. This post assumes you have already decided MCP is the right layer and want the thing built correctly.

Prerequisites

  • Python 3.10 or higher.
  • Basic comfort with async/await and type hints; the SDK generates tool schemas from both.
  • uv for environment and dependency management. pip works too, uv is faster and what the official tooling assumes.

Step 1: Set up the project

curl -LsSf https://astral.sh/uv/install.sh | sh
uv init mcp-server && cd mcp-server
uv venv && source .venv/bin/activate
uv add "mcp[cli]"

mcp[cli] pulls in the Python SDK and the CLI tools you will use to run and inspect the server. The standalone fastmcp package on PyPI wraps the same decorator API if you want it outside the official SDK; the interface below is identical either way.

Step 2: Define a tool with a schema the model can actually use

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("project-tools")

@mcp.tool()
async def get_project_status(project_id: str) -> str:
    """Get the current status and open blockers for a project.

    Args:
        project_id: The project's unique identifier, e.g. "proj_4471"
    """
    project = await fetch_project(project_id)
    return f"{project.name}: {project.status}. Blockers: {project.blockers or 'none'}"

The docstring is not documentation, it is the tool description the model reads to decide when to call this. Vague docstrings produce a model that calls the right tool with the wrong arguments, or the wrong tool entirely. Name the parameter, describe its format, give an example value, the same discipline you would use writing an API contract for a human.

Step 3: Handle errors like a server, not a script

A quickstart tool that raises lets the exception propagate and kill the process. A production tool catches what it can predict and returns a result the model can act on, because the model cannot see a stack trace, only what the tool returns.

@mcp.tool()
async def get_project_status(project_id: str) -> str:
    """Get the current status and open blockers for a project.

    Args:
        project_id: The project's unique identifier, e.g. "proj_4471"
    """
    try:
        project = await fetch_project(project_id)
    except ProjectNotFoundError:
        return f"No project found with id '{project_id}'. Check the id and try again."
    except TimeoutError:
        return "The project service timed out. Try again in a moment."

    return f"{project.name}: {project.status}. Blockers: {project.blockers or 'none'}"

Two rules follow from this. Catch specific exceptions, not a bare except Exception, so a bug you did not anticipate still surfaces in your logs instead of silently returning a friendly lie. And return the error as a string result, not a raised exception, unless the SDK's structured error type is what your transport expects, because a raised exception from inside a tool handler is exactly what took the whole server down in the quickstart version.

Where a quickstart tool call ends versus where a production one keeps goingTwo horizontal lanes show the same tool call. The top lane, labelled twenty-minute quickstart, has one box: run the function, then a direct arrow to the client, with a warning mark showing that an exception here kills the whole process. The bottom lane, labelled production, has five boxes in sequence: validate input, execute, catch known errors, log to stderr, then return a structured result to the client. Each box in the bottom lane is a place the top lane’s single box can fail silently.One box, or five checkpoints20-MINUTE QUICKSTARTrun the functionclient(or a dead process)PRODUCTIONvalidateinputexecutecatch knownerrorslog tostderrstructuredresultEvery checkpoint the production lane adds is a failure mode the quickstart lane has, silently.The quickstart works in the demo because nobody sends it bad input.
Same tool call, two implementations. The gap between the lanes is where week-two bugs live.

Step 4: Logging without corrupting the protocol

For a stdio server, this is the trap that catches almost everyone once. print() writes to stdout, and stdout is the same channel carrying JSON-RPC messages between your server and the client. One stray print("processing request") interleaves plain text into a stream that must be valid JSON, the client's parser breaks, and the failure looks nothing like a logging bug from the outside.

import logging

logger = logging.getLogger(__name__)

# Wrong for a stdio server: corrupts the protocol stream
print("Processing request")

# Correct: the standard library logging module writes to stderr by default
logger.info("Processing request")

Keep print() out of a stdio server entirely, not just out of the hot path. If the server runs over streamable HTTP instead, this specific trap does not apply, stdout is not shared with the transport, but the discipline of one logger per module and structured log lines still pays off when you are debugging a server three deployments from now.

A stray print statement corrupting a stdio JSON-RPC streamA vertical pipe represents the stdio channel between server and client, carrying a sequence of small labelled frames reading jsonrpc two point zero. Partway down, a red frame reading plain text from a print statement is inserted into the same channel. Below that point, the pipe is drawn jagged and broken, with a small burst icon labelled parse error, showing that one line of unstructured output derails every message that follows it.One print() call, one broken stream{“jsonrpc”:…}{“jsonrpc”:…}“Processing request”← print(), not logger.info()parseerrorstdout carries the protocol. Anything that is not a message breaks it.
The stdio transport has no separate debug channel. stdout is the protocol, or it is nothing.

Step 5: Test it before a client ever does

The MCP Inspector is a browser-based tool that connects to your server the way a real client would, without you writing a throwaway client first.

npx @modelcontextprotocol/inspector

Point it at your server, then run through the same checklist every time:

  1. Confirm initialization succeeds and the tool list matches what you defined.
  2. Call every tool with valid input and confirm the result shape.
  3. Call every tool with missing or malformed input and confirm you get a readable error back, not a dead connection.
  4. If the server requires auth, confirm an unauthenticated call is rejected, not silently allowed.

Skipping this step means the first invalid input your server ever sees arrives from a real user, through a real client, at a moment you were not watching logs.

Step 6: Authenticate requests, at the level you actually need today

Not every MCP server needs the full OAuth 2.1 flow the specification mandates for public remote servers, a local stdio server running on a developer's own machine inherits that machine's trust boundary. But the moment your server reaches a real system on someone else's behalf, credit data, internal tools, customer records, authentication stops being optional.

Server type Reasonable auth What breaks if you skip it
Local stdio, personal use None required, process boundary is the boundary Nothing, until you deploy it
Local stdio, shared machine Environment-scoped API key, read at startup Any process on the machine can invoke your tools
Remote HTTP, single client Bearer token per client, checked on every call Anyone who finds the URL has full tool access
Remote HTTP, public OAuth 2.1 with PKCE, per the spec Token replay, scope creep, no per-user audit trail

For the first three rows, a bearer token checked in your HTTP handler before the request reaches any tool is enough, and it is a few lines of middleware. The fourth row, the architecture, PKCE, and the CORS mistake that catches almost everyone building it, is its own post in this series and deserves the space, not a rushed section here.

Step 7: Version before someone else's client depends on you not to

A tool's name and input schema are a contract the moment a second person's client integrates against your server. Add fields, do not remove or rename them, without a version bump. Keep a CHANGELOG next to the server code, the same discipline as an API, because "it's just an MCP server" stops being true the day someone outside your team points a client at it.

For running it: stdio is right for local tools a desktop client launches directly, Claude Desktop and similar hosts spawn the process themselves. Streamable HTTP is right the moment the server needs to run somewhere the client is not, which is every server more than one person uses.

Common failure modes

  • Bare except Exception everywhere. Hides real bugs behind a generic error message and makes debugging a production incident a search through logs instead of a stack trace.
  • Docstrings written for humans, not for tool selection. "Gets the status" tells a model nothing about the argument format it needs to fill in.
  • Testing only the happy path. The Inspector takes five minutes to run malformed input through every tool; skipping it moves that five minutes to an incident.
  • No process supervision. A stdio server that crashes on one bad tool call needs a supervisor to restart it, or every tool goes down with the one that failed.
  • Treating the schema as free to change. A renamed parameter breaks every client already built against the old name, silently, until someone files a bug.

Frequently asked questions

How do I build an MCP server in Python?

Install the SDK with uv add "mcp[cli]", create a FastMCP instance, and decorate functions with @mcp.tool(). Type hints and the docstring become the tool's schema and description automatically. Run it with mcp.run(transport="stdio") for local use, or a streamable HTTP transport for a server other people's clients will reach.

What is the difference between an MCP server and a regular API?

An MCP server speaks a standard protocol that any MCP-compatible client can discover and call without custom integration code, while a plain API needs a bespoke client for each consumer. If only one application will ever call your endpoint, a plain API is less machinery for the same result; MCP earns its keep once multiple AI clients need the same tools.

Do I need OAuth for an MCP server?

Only if it is a remote server reachable by more than your own process. A local stdio server inherits the machine's trust boundary and typically needs no auth at all. A remote server the specification requires OAuth 2.1 with PKCE for, and the architecture behind that is covered in depth in the next post in this series.

How do I test an MCP server before deploying it?

Run the MCP Inspector (npx @modelcontextprotocol/inspector), point it at your server, and call every tool with both valid and deliberately malformed input. Confirm initialization succeeds, the tool list matches your definitions, and errors come back as readable results rather than dropped connections.

Can I build an MCP server in TypeScript instead of Python?

Yes. The official TypeScript SDK, @modelcontextprotocol/sdk, mirrors the same core concepts, tools, resources, and prompts, with a comparable decorator-free registration API. The failure modes in this post, uncaught exceptions killing the process, logging into a shared transport channel, untested error paths, apply the same regardless of language.

Sources

Related articles

More on guides from the HorizonLux team.

Ship AI agents that reach production

Only about 5% of enterprise agents ever go live. We build the evaluation, routing and cost control that get yours there.

Prefer email? [email protected]