Model Context Protocol from the ground up
Transports: stdio and HTTP
stdio is a pipe and HTTP is a request; both carry the same JSON-RPC frames. Choose by where the server lives, and treat the transport as the first security boundary rather than a detail.
stdio is a pipe
In the stdio transport the client starts the server as a child process and writes one JSON-RPC message per line to its stdin. The server writes one message per line to stdout. Logs go to stderr, never to stdout, because stdout is the wire. The connection lives exactly as long as the process does, and one process serves one client.
Newline framing works because JSON escapes a newline inside a string. A tool result containing several paragraphs is still one line on the wire, with the line breaks written as backslash-n. That means a reader can split on newlines without parsing first, which is why the transport is small enough to implement in an afternoon.
The child process inherits the working directory and the environment, which is convenient and dangerous. Pass only the variables the server needs, and set the directory explicitly rather than relying on wherever the host happened to start. An inherited token is a permission nobody granted on purpose.
HTTP is a request with a session
The Streamable HTTP transport uses a single endpoint. The client POSTs a JSON-RPC message; the server answers with JSON or opens a server-sent event stream for a long-running call. On initialize the server returns an Mcp-Session-Id header, and the client sends it on every later request. Later requests also carry the negotiated protocol version in an Mcp-Protocol-Version header.
A GET to the same endpoint opens a stream for messages the server initiates, such as a progress notice or a list-changed hint. Event ids make the stream resumable: a client that reconnects with Last-Event-ID picks up after the last event it saw. The older HTTP plus SSE transport used two endpoints and is deprecated, so a new server should not be built on it.
A POST that starts a long call can return an event stream instead of a JSON body. The client reads events until the response event arrives, then closes. That is the same request with a different content type, not a second protocol, and the handler does not change.
The same client, two transports
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
async with streamablehttp_client("https://example.com/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()Newline framing, by hand
Write three messages to a buffer and read them back. Confirm the tool argument containing a newline survives as one frame, then add a message with an empty params object.
The transport is the first security boundary
A stdio server is a local process with the user’s privileges. It needs no authentication because the operating system already decided who may start it, but it inherits the environment, including any token that happens to be there. Spawning a server from a configuration file the user did not write is the equivalent of running a script from an email attachment.
An HTTP server is reachable by anything that can route to it. Bind it to localhost unless it is meant to be public, validate the Origin header so a browser page on another site cannot reach it through DNS rebinding, and require a real access token. A session id identifies a conversation; it is not a credential, and it must not be the only thing standing between a caller and a tool.
TLS is table stakes for a remote server, and it is not enough on its own. Authenticate the caller, authorise the tool, and log the decision with the session id, because the transport will happily carry an unauthenticated request to a handler that trusts it.
stdout is the wire. One stray print in a stdio server corrupts the frame stream, and the client sees a parse error rather than the line you were debugging.
Choosing a transport
Use stdio when the server is a local capability: a filesystem, an editor, a database on the same machine. The lifecycle is simple, there is no port to expose and no certificate to renew. Use Streamable HTTP when the server is remote, shared by several users or scaled behind a load balancer. The protocol is the same; the operational shape is not.
The mistake to avoid is putting a local capability behind a public endpoint because HTTP felt more modern. A filesystem server exposed on a port is a remote file read for anyone who finds it. The transport should follow the trust boundary, not the other way round.
A server can support both transports behind one handler, which is often the right answer: stdio for the local developer, HTTP for the hosted product. The handler does not know which one delivered the frame, and it should not. That ignorance is the portability the protocol promised.
A session over HTTP
Initialize, send the initialized notice, then make a call with and without the session header. Confirm the missing-session request is rejected before it reaches a tool.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Why must a stdio server log to stderr?
