Chapter 1 of 8 · 15 min read
Choosing stdio or streamable HTTP
On this page
A transport is the layer that carries Model Context Protocol (MCP) messages between a
client and a server. The tool definitions, the JSON-RPC request and response objects, the
capabilities the two sides agree on during initialize — all of that is the protocol, and
none of it says how the bytes get from one process to the other. The specification defines
two answers. Under stdio, named for the standard input and standard output streams, the
client launches the server as a child process and uses the pipes it inherited as the wire:
requests go into the child's stdin, replies come back on its stdout, and no network is
involved anywhere. Under streamable HTTP, the server is an ordinary network service
listening at a URL, and clients POST their messages to a single endpoint over HTTP.
There are two of them because the servers people build come in two shapes — one that belongs to a single person on a single machine, and one that a whole organisation shares — and those shapes want opposite things from the layer underneath them. Where you meet the choice is a few lines of client configuration: a command the client should run, or an address it should call. One implementation can be deployed either way, over the same handlers and the same schemas, so the question is never which transport the code supports. It is which of the two shapes the tool is, and that is a question about who owns the process.
The subprocess is the user
There is no listener in the stdio arrangement, no port, no address to which anything else could connect, and the session lasts exactly as long as the parent's interest in it. That relationship is the whole mechanism, and it comes with a property that is easy to overlook precisely because nobody had to configure it: the child runs as the user who ran the parent.
$ ps -o pid,ppid,user,args -p 48213
PID PPID USER ARGS
48213 48110 dana /Users/dana/.local/bin/repo-index-mcp --root .
$ ps -o pid,user,args -p 48110
PID USER ARGS
48110 dana /Applications/Editor.app/Contents/MacOS/EditorProcess 48213 is dana. It has dana's home directory, dana's environment, dana's SSH agent
socket, dana's keychain access on macOS if the parent had it, and a working directory
inside dana's checkout. Nobody wrote code to arrange that.
The steps underneath that are worth walking, because "the operating system arranged it" is
the kind of sentence a reader nods at and then cannot rebuild. A process on a Unix-like
system carries its user id in the kernel's record of the process, not in anything the
program says about itself, so the identity is not a claim the program could get wrong.
fork produces a copy of the calling process, and the copy begins life holding the same
user id, the same environment block, the same open file descriptors and the same working
directory, because copying all of that is what fork is. exec then replaces the program
the copy is running while leaving those four untouched, since a program that could acquire
privilege merely by being started would empty the model of meaning — the setuid bit is the
deliberate exception, and it is not something a package manager sets on a tool server. The
child that comes out of that sequence is therefore dana's process running the server's code,
and at no step did anybody choose it. The client's configuration for the server is
correspondingly small.
{
"mcpServers": {
"repo-index": {
"command": "uvx",
"args": ["repo-index-mcp", "--root", "."],
"env": { "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" }
}
}
}Every hard question a shared service has to answer is absent from that block. Which user is calling — the one who launched it, and there is only ever one. Which repository — the one in the working directory the parent chose. Which credentials — the ones already in the parent's environment. How do we stop it — the parent exits and the pipe closes. A per-user tool wants exactly those four answers, and the parent-child relationship supplies all four for nothing.
Four different mechanisms are doing that work, and they are worth separating because they
fail in different ways later. The identity comes from the kernel, which stamped the child
with the parent's user id at fork and has been enforcing it ever since. The repository
comes from the working directory, which was copied at the same moment and which the editor
had already set to the workspace root before it spawned anything. The credentials come from
the copied environment block, which is why the env entry above only has to name a variable
rather than hold a secret — the value is already in the parent. And the shutdown comes from
the pipe: when the parent exits, its end of the child's standard input closes, the child's
next read returns end-of-file, and a read loop that treats end-of-file as the end of the
session stops on its own. Four questions, four mechanisms, and no fifth question left
over — which is why the configuration block is short. It is not that the framework is clever;
it is that there is nothing left to configure.
What a shared deployment has to invent
A server reached over HTTP inherits none of that, because the thing on the other end of the
connection is not a parent, it is a stranger who arrived over the network. The process
starts once, under a service account named something like svc-repo-index, and stays up for
weeks. Requests arrive from hundreds of people and from automation, concurrently, and each
one has to be identified before the handler can decide what it may see. Every question the
subprocess model answered structurally now needs code, configuration, and an operator.
| stdio | streamable HTTP | |
|---|---|---|
| Who the process runs as | the calling user, inherited from the parent | one service account, for everybody |
| How a caller is identified | it is the parent; there is no other caller | per request, from a token the caller presents |
| Lifetime | exactly as long as the client that spawned it | independent of every session it serves |
| Concurrency | one client, one process | many clients, one process, at once |
| Reachable by | nothing; there is no address | anything that can route to the address |
| Working directory | the user's checkout, chosen by the parent | whatever the container image and mounts provide |
| Deployment unit | a binary or package on each laptop | an image, a rollout, a load balancer, a DNS name |
| Where logs go | the parent's stderr, on one machine | a log pipeline, correlated across replicas |
Eight rows is more than anyone can hold, and they do not have to be held, because every one of them is the same fact restated for a different concern. Under stdio the process exists because one person's client wanted it and stops existing when that client loses interest, so the process is that person for as long as it runs. Every stdio column follows from that in one step: one identity because there is one owner, no caller identification because the owner is not a caller, one concurrent client because a second client would have spawned its own copy, no address because nothing was asked to reach it, the owner's checkout because that is where the owner was standing, a per-laptop install because the owner is on a laptop, and the owner's stderr for logs because that is what the owner can read. Under HTTP the process belongs to nobody in particular, and every column flips for that one reason. Reading the table as eight independent differences makes the migration look like eight problems; reading it as one fact with eight consequences is what makes the estimate come out right.
The row that hurts most is the working directory, because a requirement written as "it should read the local checkout" is not one a shared host can satisfy. There is no local checkout on a machine in another region; there are as many of them as there are laptops, in states that exist nowhere else. Somebody has to decide what the shared server reads instead, and the honest options are to index the pushed refs from the origin remote on a schedule, or to accept a repository and revision as tool arguments and fetch that revision on demand, or to keep an internal mirror and expose it read-only. Each has a real cost: a scheduled index is stale by up to its interval; on-demand fetches make the first call to a cold revision take seconds instead of milliseconds; a mirror needs its own credentials to the source. What none of them can do is see an engineer's unsaved buffer, which means the shared deployment answers a slightly different question from the local build, and the tool description should say so.
The second row that matters is identification. Under stdio the question "may this caller
read /etc/secrets?" is meaningless, since the caller is the user and the operating system
has already decided. Under HTTP the same question is live on every single request, and
getting it wrong is not a subtle failure — it is one person's access quietly becoming
everybody's. That is the subject of the third chapter of this topic, on establishing who is
calling, and it is reliably the largest single piece of work in a migration.
Latency is not the criterion, though people reach for it first
The usual first instinct is to reach for a performance argument: stdio is local, so it must
be the fast one, and speed decides. Measured on a laptop, a trivial tools/call round trip
over pipes lands around 0.4 ms end to end, and the same server on loopback HTTP lands around
1.2 ms; against a service in another region you are paying 15–25 ms of network on top, plus
TLS on a cold connection. Those numbers are real, and they are almost never the deciding
factor, because a repository search that greps a monorepo takes 200–900 ms of actual work.
An 0.8 ms difference in framing is under half a percent of that. If your tools are that
cheap and that chatty, transport latency starts to matter, and at that point you are usually
better off making the tool less chatty.
The arithmetic is worth doing rather than taking on trust, because the instinct behind the performance argument is right about the ratio and wrong about the quantity that decides anything. Loopback HTTP at 1.2 ms against pipes at 0.4 ms is three times the framing cost, and a threefold regression inside a tight loop would be alarming. What a user waits for, though, is the difference rather than the ratio, and the difference is the subtraction of those two: eight tenths of a millisecond. Divide that into the 200 ms a fast repository search takes and it is four parts in a thousand; divide it into the 900 ms a slow one takes and it is under one part in a thousand. The reason the ratio does not track the impact is that both framings are paid once per call and neither grows with the work, so the more the tool actually does, the smaller a fixed threefold gap becomes as a share of the whole.
The one case where the arithmetic changes the answer is a call across regions. There the 15–25 ms of network is a tenth of the fast search rather than the four parts in a thousand that separated the two local framings, and it is large enough to be felt. Even then the conclusion is not to change transport, because the same network sits under the shared deployment whichever framing it uses; it is to issue fewer calls.
Process ownership decides instead, and it decides in both directions. A tool whose value comes from being the user — their checkout, their credentials, their environment, their unpushed work — wants stdio, because that is the only arrangement where being the user is free. A tool whose value comes from being shared — one index, one audit trail, one set of privileges nobody has locally, one place to revoke access — wants HTTP, because the whole point is that it is not any particular person's process. Ask which of those two the tool is, and the transport falls out.
The single-artefact shortcut and what it costs
The proposal that reliably appears at this point is to run the HTTP build on localhost for the local case, so there is one binary, one code path, one release. It is a genuinely attractive trade — build once, test once, no divergence between what developers run and what production runs — and it throws away every property that made the local case cheap.
Start with identity. A localhost HTTP server has no parent to inherit from; it is started by
a launch agent or a terminal, and if the editor and a CI runner and a stray script all
connect to 127.0.0.1:8931, the server cannot tell them apart without an authentication
scheme somebody now has to build for a single-user machine. Then the filesystem: the process
has a working directory chosen at start time, so the editor has to tell it which workspace
each request concerns, and the server has to decide whether it will honour arbitrary paths
from its caller — which is a path-traversal surface that did not exist when the parent simply
chose the root. Then the lifecycle: nothing kills the process when the editor closes, so it
lingers with an open port and a stale index, and you get to write the supervision and
health-check logic the pipe was doing for free.
The belief underneath the proposal deserves to be stated in its own words, because it is not
careless and it is nearly true. It is that 127.0.0.1 is a kind of address only local things
can use, so a server bound there stays private to the machine in every sense that matters,
which makes the transport an implementation detail and the second build pure duplication.
The first half holds: no packet from another host is delivered to a loopback socket, and
every firewall rule and VPN policy in the company is satisfied by a connection that never
leaves the machine. The half that fails is "only local things", which is being read as "only
the client". A loopback socket is reachable by every process running on that machine, and by
every page loaded in a browser on it, and neither of those is the editor. What the
parent-child relationship was enforcing was never an address; it was that there existed
nobody else who could speak.
The last cost is the one that gets the proposal rejected in review. An unauthenticated
listening socket now exists on every laptop in the company, and a listening socket is
reachable by things other than the editor — by anything else on the machine, by anything on
the network if the bind address is wrong, and by any web page the engineer visits, because a
page's JavaScript can post to http://127.0.0.1:8931 from the engineer's own browser. That
last one surprises people, and it is concrete enough to get its own treatment later in this
topic. For now it is enough that "one artefact" buys build simplicity and sells a security
property, and the trade is a bad one when the alternative is a transport flag over the same
handler code.
What the two builds should genuinely share is everything above the transport: the tool
schemas, the ranking, the argument validation, the error messages. Most server frameworks
make this a single line at the bottom of the entry point, choosing between
run(transport="stdio") and an ASGI application object mounted at a path, and the four
hundred lines of ripgrep logic never learn which one ran.
What does change beneath that line is what standard output means. Under stdio the child's
stdout is the wire the client reads messages from, which is why the first working build of
the editor version failed with a live process and no messages at all: a handler logged its
progress with print(), and those bytes went where the protocol's bytes were supposed to
go. That is the subject of the next chapter, and it is the first of several properties that
look like bugs in the server and are really properties of the transport it was deployed
over.
Process ownership is therefore the question to ask first, and it is answerable before any
code is written. A tool that is only ever useful because it is standing where its user is
standing — inside their checkout, holding their tokens, seeing their unsaved work — is a
subprocess, and every property it needs arrives from fork and exec at no cost. A tool
that is useful because it is one thing that many people share is a service, and every one of
those properties has to be constructed: an identity per caller, a credential per caller, a
lifetime independent of any session, a deployment, a log pipeline.
The mistake worth naming is trying to have both from one deployment. Running the shared build on each laptop keeps the artefact count at one and reintroduces every question that made the shared build expensive, on machines with no operator and no perimeter. Running the local build on a server keeps the simplicity and loses the only thing the shared deployment was for. Two entry points over one implementation is the arrangement that stays honest, and the rest of this topic is the operational detail of each.
Transports — read the current version at https://daviddominguez.dev/learn/en/transports/choosing-stdio-or-streamable-http