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:
- A machine boundary. The tools run somewhere else: a workstation with the
licensed solver, a box with the GPU, a hosted service. Note this is about
machines, and packaging does nothing to it: a stdio server inside
docker runis still a subprocess speaking stdio. - A URL-only client. The Claude API’s
MCP connector
takes servers as
{"type": "url", ...}and its limitations section states plainly: “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, so the constraint is per client, and worth checking rather than assuming.) The bridge exists for the opposite direction: mcp-remote connects “an MCP Client that only supports local (stdio) servers to a Remote MCP Server, with auth support”. - Real authentication. A stdio config puts API keys in plaintext JSON on every client machine. HTTP moves credentials behind bearer tokens or OAuth, held and revoked in one place. This is simultaneously a trigger and a bill; the OAuth resource-server plumbing is real work, which is why it should be pulled by an actual multi-user requirement instead of installed on spec.
- One process must own a resource. A warm solver kernel, a cache that has to stay coherent across callers, a model that takes minutes to load, or simply memory: an 18-tool server importing the scientific Python stack times one subprocess per client adds up. The multi-client argument is really an argument about shared state.
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
- A SysML v2 model that runs its own verification · 2026-08-21
- Data provenance for agentic AI workflows · 2026-08-17
- An agent designed a maritime search radar and beat my reference on cost · 2026-08-13