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/awaitand type hints; the SDK generates tool schemas from both. uvfor environment and dependency management.pipworks too,uvis 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.
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.
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:
- Confirm initialization succeeds and the tool list matches what you defined.
- Call every tool with valid input and confirm the result shape.
- Call every tool with missing or malformed input and confirm you get a readable error back, not a dead connection.
- 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 Exceptioneverywhere. 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.



