DEV Community

Craig Solomon
Craig Solomon

Posted on

stdout is the protocol: what changes when an MCP server moves from stdio to HTTP

On stdio transport, an MCP client starts your server as a child process and talks to it over stdin and stdout. That is the whole channel. Stdout is not "where your program prints things while the real protocol happens somewhere else." Stdout is the wire.

Which means this tool is broken:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo")

@mcp.tool()
def list_tables() -> list[str]:
 print("listing tables") # this goes onto the JSON-RPC stream
 return ["users", "orders"]
Enter fullscreen mode Exit fullscreen mode

The function returns the right value. The client still fails, because listing tables\n arrived in the middle of a message stream that only expects framed JSON-RPC. Depending on the client you get a parse error, a tool that never appears, or a server that disconnects during startup with nothing useful on screen.

The failure is confusing because the cause and the symptom are far apart. You added a debug line to a tool and the whole server stopped loading in Claude Desktop.

The fix is one line, and it is about handlers, not about print

Send everything human-readable to stderr:

import sys
import logging

handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler])

log = logging.getLogger("demo")

@mcp.tool()
def list_tables() -> list[str]:
 log.info("list_tables called")
 return ["users", "orders"]
Enter fullscreen mode Exit fullscreen mode

logging.basicConfig() already defaults to stderr, so you may think you get this for free. You do not, reliably. Whoever calls basicConfig first wins, and if an imported module configures the root logger with stream=sys.stdout, your log lines land on the protocol channel. Configure the handler explicitly in your entrypoint, before you import anything chatty.

The other source of stray bytes is import-time output from a dependency: a banner, a deprecation notice someone routed through print, a progress bar. For Python-level writes you can fence the import:

import sys
import contextlib

with contextlib.redirect_stdout(sys.stderr):
 import chatty_dependency
Enter fullscreen mode Exit fullscreen mode

redirect_stdout swaps sys.stdout for the duration of the block. It does not touch file descriptor 1, so a C extension that writes to fd 1 directly walks straight past it. If you hit that, you are into os.dup2 territory, and at that point it is usually easier to debug over HTTP and keep the dependency out of the stdio path.

A useful habit while writing tools: treat print as a syntax error in server code. Grep for it before you ship.

The switch itself is boring, which is the point

Both transports run the same tool functions. Picking one is an environment decision, not a code decision:

import os

if __name__ == "__main__":
 transport = os.environ.get("MCP_TRANSPORT", "stdio")
 if transport == "streamable-http":
 mcp.run(transport="streamable-http")
 else:
 mcp.run(transport="stdio")
Enter fullscreen mode Exit fullscreen mode

Name the variable whatever you like. The value of doing it this way is that your local Claude Desktop setup and your deployed container are the same artifact, so a tool you debugged locally is the tool that runs remotely.

What is not boring is everything the switch implies.

What changes when you stop being a subprocess

The trust boundary moves. On stdio, your server is a child process of the client, on the user's machine, running with the user's privileges. Nobody else can reach it, because there is nothing to reach. The only thing that drives it is the model sitting in front of one user. On streamable-http, you have a listening socket. Now the interesting question is who can open a connection to it, and the answer is no longer "only the person who launched it."

That is the moment the input guards stop being a nicety. A file tool that resolves paths inside a sandbox root, a SQL tool that rejects anything but reads, an HTTP tool that checks the destination host against an allowlist: on stdio those protect a user from their own model. Over HTTP they protect you from anyone who can reach the endpoint.

Process state becomes shared state. One stdio process serves one client. One HTTP process serves many sessions at once. Module-level globals that felt harmless locally stop being harmless:

# fine as a subprocess, a bug as a server
CURRENT_DIR = None

@mcp.tool()
def set_dir(path: str) -> str:
 global CURRENT_DIR
 CURRENT_DIR = path
 return "ok"
Enter fullscreen mode Exit fullscreen mode

Two callers, one variable. Write tools stateless: take the path, the query, the URL as an argument on every call, and derive everything else from it. If you need a cached resource like a database connection, make it per-request or make it thread-safe on purpose rather than by accident.

Blocking calls start costing other people. Under stdio, a synchronous call that blocks for a while only makes one user wait. Under HTTP, it can stall concurrent requests. Use an async HTTP client for outbound calls, and push blocking work such as a synchronous database driver into a thread with asyncio.to_thread instead of calling it directly inside an async handler.

Debug without the transport at all

The best answer to "I cannot print, so how do I see what is happening" is to stop debugging through the client. Your tool functions are ordinary Python. Call them directly:

import pytest

def test_read_text_file_rejects_escape():
 with pytest.raises(ValueError):
 read_text_file("../../etc/passwd")
Enter fullscreen mode Exit fullscreen mode

No transport, no client, no handshake, and the denial cases are the ones you actually want pinned down. When something misbehaves through Claude Desktop, checking whether the same call misbehaves in a test tells you immediately whether you have a tool bug or a transport bug. Those have completely different fixes and it is easy to spend an afternoon on the wrong one.

Limits

Routing logs to stderr is hygiene, not a feature. It stops a specific, repeatable crash. It does nothing for a tool that returns the wrong data.

An env var that switches transports does not give you authentication, TLS, or rate limiting. streamable-http makes the server reachable. Deciding who may reach it is a deployment question your mcp.run call does not answer, and if you expose an endpoint on the open internet with no auth in front of it, per-call input guards are not going to save you.

Those guards are also input validation, not authorization. They answer "is this call shaped like something allowed" and never "is this caller allowed to see this row." If different callers must see different data, that belongs in a layer that knows who the caller is.

And if you only ever run inside Claude Desktop on your own laptop, the HTTP path may be work you do not need. The reason to wire it early is that retrofitting statelessness into tools written against a single-process assumption is more annoying than writing them stateless on day one.

The MCP Starter Kit is the Python MCP server I built and maintain, with both transports behind one env var, the SSRF, SQL and path guards, and a pytest suite already in place: https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto

Top comments (0)