Skip to content
John Hodge

← Blog

MCP transport is a lifecycle decision

APAB, my agentic phased-array design package, starts its MCP server with apab mcp serve. Add one flag, apab mcp serve --transport http, and the same 18 tools come up on a local port speaking streamable HTTP. The tool schemas are identical, the physics underneath is identical, and an agent calling synthesize_taper cannot tell the difference.

So the transport flag decides none of the things the server is about. What it decides is lifecycle: who starts the server, who ends it, how many clients can reach it, how credentials reach it, and whether a returned file path means anything to the caller. Those are deployment questions, and they have concrete answers.

My answer, after building four of these servers and a benchmark harness that drives them: start on stdio, and move a server to streamable HTTP when a specific trigger shows up. What follows is what each transport does in the current spec, what stdio has cost me in production runs, and which triggers justify a hosted deployment.

Two transports remain

The Model Context Protocol defines two transports in the current spec revision, 2026-07-28. stdio runs the server as a subprocess of the client, exchanging newline-delimited JSON-RPC messages over stdin and stdout, with stderr reserved for logging. Streamable HTTP runs the server as an independent process behind a single HTTP endpoint; every JSON-RPC request is its own POST, and the server answers each one with either a single JSON object or a server-sent-events stream scoped to that request.

The third transport you may remember, HTTP+SSE from the 2024-11-05 revision, has been deprecated since 2025-03-26. The spec now says new implementations “SHOULD NOT adopt it” and lists it in the deprecated features registry as eligible for removal.

Revision 2026-07-28 also rebuilt streamable HTTP itself, removing protocol-level sessions and the standalone GET stream. The same revision retired the initialize handshake across the protocol, stdio included, in favor of per-request metadata. I covered what those removals mean for tracing in auditing tool-call traces in MCP agents; here it matters because it changed the operational price of HTTP.

Start with stdio

The stdio transport’s defining property is that the client owns the server’s entire lifecycle. The spec spells it out: the client launches the subprocess, initiates shutdown by closing stdin, waits, and escalates to SIGTERM and then SIGKILL if the server lingers. If the server dies unexpectedly, the client restarts it, and because the protocol is now stateless, in-flight requests can simply be retried against the fresh process.

Everything I like about stdio follows from that ownership. There is no listening socket, so there is no port to pick and no authentication surface to design. Each client gets its own private server process, so isolation between clients exists by construction. It works on a plane. Secrets arrive as environment variables in the spawn call instead of traveling over a network.

It is also what the tooling reaches for first. The FastMCP server API in the official Python SDK, which all my servers import as mcp.server.fastmcp, runs stdio when run() is called with no argument, and the separate FastMCP 2.x project lists local development, Claude Desktop integration, command-line tools, and single-user applications as what stdio suits. All four of my public MCP servers (APAB, opensatcom, antenna-cad, and sysml2kit) default to it.

Subprocess ownership pays off hardest in benchmarking. AEDL, my agent-evaluation harness, launches Claude Code headless with --mcp-config plus --strict-mcp-config; the adapter’s comment explains that the pair means “exactly these servers and nothing inherited from the operator”, and the config’s sha256 lands in the run manifest. That hash earns its keep twice. It pins the tool surface for reproducibility, and it guards the sharpest edge of stdio configs: an mcpServers block is arbitrary command execution (npx -y whatever runs whatever), so knowing exactly which bytes were live during a run is a security record as much as a scientific one.

What stdio actually costs

Three defects in my own projects, all traceable to the same root: a subprocess boundary looks transparent and is anything but.

Clients spawn servers with an environment you did not choose. AEDL’s instrumentation counts physics-model calls through env-configured shims, and seven t3-001 bundles in a row recorded zero instrumented calls, including the MCP arm where the servers were expected to inherit the shim. The variables were set in the harness process and never arrived in the servers. The adapter docstring records the conclusion I drew, that “the Claude CLI spawns MCP servers with its own curated environment, not a copy of the CLI process env”, and the fix was rewriting the config at run time to inject an env block per server, with both config hashes recorded; the next bundle logged 108 calls. Treat the specific mechanism as my observation rather than documented behavior, since Claude Code’s MCP docs specify the per-server env block and the CLAUDE_PROJECT_DIR they set without saying what else a spawned server inherits. The portable lesson is to declare what the server needs instead of assuming your shell reaches it.

The protocol owns stdout. The spec: the server “MUST NOT write anything to its stdout that is not a valid MCP message”. One stray print, one logging handler with the default stream, one library that greets you on import, and the JSON-RPC channel is corrupt. APAB’s observability module carries the scar as a comment: “stderr, never stdout: the MCP stdio transport owns stdout, and a span dump there corrupts the JSON-RPC stream.”

Per-request context needs the protocol’s help. Environment variables cross the process boundary exactly once, at spawn. AEDL forwards a TRACEPARENT into each server’s env block so server-side spans join the run’s trace, and the changelog records what that cannot do: before the fix, one bundle held eight server spans with eight distinct trace ids and no parents. Even after it, every span is a sibling under one run-level root, because the variable is read once at server start. Attributing a span to the agent turn that caused it needs _meta.traceparent on each tools/call, since _meta is stdio’s only per-request envelope. Streamable HTTP is better here: it has real per-request headers, and the 2026-07-28 revision leans into them, mirroring Mcp-Method and Mcp-Name into HTTP headers precisely so “intermediaries (load balancers, gateways, observability tooling) can route and inspect requests without parsing the body”. Whether a given client populates trace headers on those requests is a separate question, and an unmeasured one.

These costs are the price of the boundary. Budget real engineering attention there, which is the attention you saved by skipping auth and TLS.

The triggers that move a server to HTTP

The trigger I hear most often is “a second client”, and it is the one I would retire. Claude Code and Claude Desktop can each spawn their own copy of the same stdio server today, and nothing breaks; N clients means N subprocesses, which is the normal working state. Parallel subagents inside one client are usually fine too, since a subagent that inherits or references a server by name shares the parent session’s connection; an inline server definition in a subagent starts its own. The triggers that have actually mattered:

No trigger, no URL. That has been the outcome for all four of my servers.

What statelessness removed from HTTP

The strongest 2025-era argument for staying on stdio was operational: streamable HTTP came with server-assigned session ids, a standalone GET stream for server-initiated messages, and resumable streams to manage, which meant sticky routing and session state in your deployment. Revision 2026-07-28 deleted all three. Sessions and the GET stream are gone, the spec now says “resumable SSE streams via Last-Event-ID are not supported”, and server-initiated requests were replaced by results that embed input requests for the client to answer on a retry. Every request stands alone and carries its own protocol version, so ordinary tool traffic scales horizontally, or runs on serverless platforms, without session affinity.

What survives per request is the SSE response stream: a server answering a slow tools/call can stream notifications/progress and then the final response on that one connection. Cancellation is the transport itself, since closing the stream cancels the request. One long-lived stream did survive the cull, and it is the piece to watch before promising yourself a stateless deployment: a client that wants server-initiated change notifications sends subscriptions/listen, whose response stream stays open for the life of the subscription. That is a sticky connection by another name, and the spec’s keep-alive guidance is aimed squarely at it.

The remaining bill is the one the spec prints in normative capitals. Servers “MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks” and answer bad ones with 403; locally they “SHOULD bind only to localhost (127.0.0.1)”; and they “SHOULD implement proper authentication”. There is also a bill the spec can only gesture at: middleboxes. The same page tells servers to send X-Accel-Buffering: no so reverse proxies deliver SSE events instead of buffering them, and to emit keep-alive comment lines so idle timeouts in intermediaries do not sever long streams. My tools wrap EM solvers that can hold a request open for many minutes; every proxy and load balancer between the agent and that solver is a new place for the connection to die. A subprocess pipe has no middleboxes.

Localhost HTTP sits between the two

Between subprocess and hosted service sits a configuration that gets less attention than it deserves: apab mcp serve --transport http bound to 127.0.0.1. The SDK defaults put the MCP endpoint at http://127.0.0.1:8000/mcp.

This keeps the properties that make local development pleasant, since the server still shares your filesystem and your checkout, while adding the one thing stdio structurally lacks: a single process that outlives any client and owns the warm state. Several local clients can point at the same port, and the solver kernel loads once.

It is also exactly where the spec’s security language bites. DNS rebinding is an attack on localhost services: a malicious web page resolves its own domain to 127.0.0.1 and reaches a server that believed it was private, which is why Origin validation is a MUST rather than advice for hosted deployments only. Bind to 127.0.0.1, validate Origin, and the local server stays local instead of becoming an accidental public API.

The flag is the easy half

The reason the transport can be a one-flag decision is that it was designed to be. My server conventions doc makes it a family rule: x mcp serve, default transport stdio, constrain the CLI to choices=["stdio", "http"], and map the user-facing http to the SDK’s streamable-http name. My own compliance is imperfect. sysml2kit takes a bare string option and hand-validates it in the command body instead of declaring the choices, and antenna-cad keeps its server in agent/server.py where the convention reserves agent/ for orchestration and asks for mcp/. Neither defect reaches an agent, which is the point: conventions decay in the places nobody is looking, and a served tool surface hides its own drift well.

The hard half is the payload contract. APAB’s tools return artifacts as file paths inside a run bundle, and that contract is portable across transports only while the client and server share a disk. Flip the flag on a laptop and nothing changes, because localhost HTTP still shares the filesystem. Put the same server behind a URL on another machine and every returned path points at a disk the client cannot read. The transport migrated in one flag; the API silently broke.

The remote-ready pattern is already in the protocol: expose bundles as MCP resources with URI templates like x://runs/{run_id}/artifacts/{path}, so the client fetches artifact bytes through the same connection the tool call used. APAB ships that surface today, registering apab://runs/{run_id}/artifacts/{path} alongside its manifests, so the remaining work is on the tool returns rather than the resource layer. Building outputs that way from the start costs little on stdio and is the difference between a transport flag and a rewrite later.

What this does not show

I have no stdio-versus-HTTP latency measurement. For my tool class the question is dominated by the tools themselves: a solver that runs for minutes makes microseconds of framing irrelevant, and the middlebox-timeout point above is an architectural claim about failure modes rather than a benchmark. Treat any bare “stdio is faster” claim as unmeasured until someone shows the numbers.

My benchmark results comparing MCP-attached agents against library-only agents, 0/3 versus 2/3 on a satcom terminal task and 1/3 versus 3/3 on a search radar, say nothing about transports: both arms ran the same stdio servers, and the effect is about tool availability. Whether popular clients populate trace context headers on streamable HTTP requests is also unmeasured here; I assert only that the transport gives them somewhere to put it.

What this looks like in my own projects

The recommendation above is a description of the repos. APAB, opensatcom, antenna-cad, and sysml2kit all serve stdio by default and all four carry the --transport http flag; none of them has hit a trigger that forces a hosted deployment, so none has one. AEDL sits on the client side, driving Claude Code headless against those servers with a pinned, hashed MCP config, per-server env injection for instrumentation, and a forwarded TRACEPARENT. One detail I like: the config hash pins different bytes per transport, command plus args plus env for stdio versus url plus headers for HTTP, so the manifest records the transport decision as a side effect of recording everything else.

The APAB 0.3.0 post covers the observability layer that made the stdout and TRACEPARENT rules necessary, and the stack overview shows where the MCP layer sits among the solvers and evaluators. The eval-harness methodology these runs follow is in how to build an eval harness.

Try it

Both transports, same server, five minutes:

# edgefem carries the three full-wave tools; without it you get 15 of the 18
pip install "apab[ollama,edgefem]"

# stdio: the client owns the process
claude mcp add apab -- apab mcp serve

# streamable HTTP on localhost: you own the process
apab mcp serve --transport http &
claude mcp add --transport http apab-http http://127.0.0.1:8000/mcp

Call a tool through each and diff the experience. The tools will not care. The day one of the triggers shows up, neither will your server code, provided your payloads never assumed a shared disk.

Every spec claim above was checked against the 2026-07-28 revision at modelcontextprotocol.io, and every quote from my own repositories was re-read from source, most recently on 2026-08-30. Run counts and tool counts come from the committed run bundles and the registered tool decorators, re-counted the same day. Client-behavior claims cite vendor documentation as of that date; client behavior changes faster than specs, so re-verify before relying on them.

This is an independent analysis written on my own time, using the public sources cited above. The projects mentioned are independent work. The views are my own and do not represent any current or former employer.

Frequently asked questions

Do I have to rewrite my MCP server to switch from stdio to streamable HTTP?

With FastMCP and the official SDKs, no: the same server object runs under either transport, so the switch is an argument to run(). What does need rework is any tool that returns file paths or assumes it shares a disk with the client. That contract holds for a subprocess and breaks the moment the server sits behind a URL on another machine, which is why returning MCP resources is the remote-ready pattern.

Why does my stdio MCP server not see my environment variables?

Do not assume a spawned server inherits your shell. In my AEDL benchmark, seven run bundles in a row recorded zero instrumented physics calls because the instrumentation variables never reached the server processes, and the fix was writing them into each server's env block in the MCP config. Client documentation is generally quiet about what else a stdio server inherits, so declaring what the server needs is the portable move.

Why does printing to stdout break my MCP server?

On the stdio transport the protocol owns stdout: the spec says the server MUST NOT write anything to stdout that is not a valid MCP message, and messages are newline-delimited JSON-RPC. A stray print or a logging handler pointed at stdout corrupts the stream. Logs belong on stderr, which the spec reserves for exactly that and which clients may capture, forward, or ignore.

Can claude.ai or the Claude API connect to a stdio MCP server?

Not directly. The Claude API's MCP connector accepts only URL-based servers over HTTP and its docs state that local stdio servers cannot be connected directly; claude.ai custom connectors likewise take a URL. Claude Desktop and Claude Code still run local stdio servers. To reach a remote HTTP server from a stdio-only client, the mcp-remote bridge covers the opposite direction.

Do streamable HTTP servers still need session management?

Not as of spec revision 2026-07-28, which removed protocol-level sessions along with the Mcp-Session-Id header, the standalone GET stream, and Last-Event-ID resumability. Every request is independent and carries its own protocol version, which is what lets a streamable HTTP server scale horizontally with no session affinity. Servers interoperating with 2025-11-25 or earlier clients still need the legacy behavior.

More in AI agents