08 · The stdio Transport & Local Servers¶
How stdio transport actually works¶
When a host connects to a local MCP server, it doesn't open a network
socket — it spawns your server as a subprocess and talks to it over
that process's standard input and standard output. This is the stdio
transport, and it's what mcp.run() defaults to when you don't specify
anything else.
Concretely: the host runs something equivalent to
subprocess.Popen(["python", "server.py"], stdin=PIPE, stdout=PIPE). Every
JSON-RPC message the host sends gets written to your server's stdin, one
JSON object per line, newline-terminated. Every message your server sends
back gets written to stdout in the same format. There's no HTTP, no
sockets, no ports to configure — just a pipe in each direction.
This is why stdio is the default and simplest choice for local servers:
there's effectively zero setup beyond "run this command," which is exactly
what you'll register in claude_desktop_config.json in the next lesson.
The one rule you cannot break¶
Because stdout is the literal protocol channel, anything your server prints to stdout other than well-formed JSON-RPC messages corrupts the stream and breaks the connection — often in confusing ways, since the failure shows up as a parse error on whichever message happened to follow the stray output.
This means a plain print() call anywhere in your server's code, including
deep inside a library you're calling, is a real bug:
@mcp.tool()
def add(a: int, b: int) -> int:
print(f"Adding {a} and {b}") # BUG: this corrupts the stdio stream
return a + b
That innocent-looking debug print() writes Adding 2 and 3 straight into
the same stream the client is trying to parse as JSON-RPC. The client will
fail to parse it, and depending on timing, may also fail to parse the
next legitimate message because the stream is now out of sync.
Log to stderr instead¶
Standard error is a separate stream, untouched by the protocol — it's the
right place for anything you want to see while debugging, and FastMCP
servers can write there freely:
import sys
@mcp.tool()
def add(a: int, b: int) -> int:
print(f"Adding {a} and {b}", file=sys.stderr) # safe
return a + b
Or, more idiomatically, use Python's logging module configured to a
stream handler on stderr (the standard library default for
logging.StreamHandler() with no argument), which gives you levels and
formatting for free without any risk of touching stdout.
Why this doesn't apply equally to every transport¶
This specific gotcha is unique to stdio. Lesson 2 of Level 2 covers Streamable HTTP transport, where the protocol messages travel over HTTP request/response bodies instead of a shared process stream — ordinary console output there doesn't collide with anything. But stdio is what every local server you build in this level uses, so the habit of never touching stdout starts now.
Exercise¶
Take the server.py from lesson 4 and deliberately add a print()
statement inside the add tool, pointed at stdout. Run it through the MCP
Inspector and call add — observe what actually happens (a parse error, a
hang, or a garbled response, depending on your SDK version and timing).
Then fix it by redirecting that same debug line to sys.stderr and confirm
the tool call works cleanly again.