Every MCP integration bug report eventually traces back to the same misunderstanding: people treat the Model Context Protocol like a REST API. It isn't one. It's a stateful, bidirectional JSON-RPC session with a strict lifecycle, and its two standard transports — stdio and Streamable HTTP — encode that statefulness in ways that bite you if you don't know the rules. Here are the rules, straight from the spec.
stdio: your server is a subprocess that must never print
In the stdio transport, the client launches the MCP server as a subprocess and speaks JSON-RPC over the pipe: the server reads messages from stdin and writes them to stdout. Framing is as primitive as it gets — messages are delimited by newlines and must not contain embedded newlines. There's no length prefix, no envelope, no checksum. One message per line.
That minimalism is why the spec's two hardest requirements exist:
The server MUST NOT write anything to its stdout that is not a valid MCP message.and symmetrically, the client must not write anything to the server's stdin that isn't a valid MCP message. A single stray print() for debugging, a dependency that logs to stdout on import, a shell wrapper that echoes a banner — any of these inject bytes into the framing layer, and the client's JSON parser chokes on the next line. This is the single most common way a working MCP server breaks when someone adds "just one log line." The spec gives you exactly one escape hatch: the server may write UTF-8 strings to stderr for logging, and clients may capture, forward, or ignore that stream. All diagnostics go to stderr. No exceptions.
Shutdown is equally transport-level: no goodbye message exists in the protocol. The client closes the child's stdin, waits, escalates to SIGTERM if the server doesn't exit within a reasonable time, and finally SIGKILL. If your server blocks on something other than reading stdin, it will eat the SIGTERM path on every disconnect.
The handshake is not optional
Regardless of transport, initialization must be the first interaction. The client sends an initialize request carrying three things: the protocol version it supports, its capabilities, and its implementation info. The server must respond with its own. Then — and this trips people up — the client must send a notifications/initialized notification before normal operation begins. It's a three-step handshake, not two.
Version negotiation is pleasantly concrete: the client sends the latest version it supports (e.g. "2025-06-18"). If the server supports it, it must echo the same version; otherwise it must respond with another version it supports — its latest — and if the client can't live with that, it should disconnect. There's no range syntax, no semver matching. One offer, one counter-offer, done.
Capabilities are the feature flags of the session. The client can declare roots (filesystem roots), sampling (the server may ask the client's LLM to generate), and elicitation; the server declares prompts, resources, tools, logging, and completions, with sub-capabilities like listChanged (change notifications for prompts/resources/tools lists) and subscribe (per-resource change subscriptions). The operational rule is strict: both parties must only use capabilities that were actually negotiated. A server that sends notifications/resources/list_changed without having declared listChanged is out of spec, even if most clients happen to tolerate it.
Streamable HTTP: one endpoint, two content types
The original remote transport (protocol version 2024-11-05, now deprecated) used two endpoints: the client opened a long-lived SSE connection, the server's first event was an endpoint event containing a URI, and all client messages were POSTed there. That design hard-required an always-open SSE connection per client and made the server inherently stateful — awkward behind load balancers and impossible on infrastructure that caps connection lifetimes.
Streamable HTTP, which replaced it in the 2025-03-26 revision, collapses everything into a single MCP endpoint that supports both POST and GET, and makes the streaming optional. The core mechanic:
- Every JSON-RPC message from the client is a new HTTP POST to that endpoint, with an
Acceptheader listing bothapplication/jsonandtext/event-stream. - If the body is a notification or response, the server returns 202 Accepted with no body.
- If the body is a request, the server chooses its answer format per call:
Content-Type: application/jsonfor a single plain JSON response, orContent-Type: text/event-streamto open an SSE stream for that request. The client must support both.
That third point is the heart of the design. A trivial server can answer every tools/call with one JSON object and never touch SSE — it behaves like a plain HTTP API. A rich server can open a stream per request and push progress notifications, log messages, even its own JSON-RPC requests back to the client before delivering the response, as long as those messages relate to the originating request. After sending the response, it should close that stream.
The client can also issue a GET to the same endpoint to open a general listening stream for server-initiated traffic. Servers that don't support it return 405 Method Not Allowed — which is why a bare GET against many MCP endpoints 405s and why that's not a bug. On a GET stream the server must not send responses (those belong to the POST that carried the request), and across multiple simultaneous streams it must send each message on exactly one — no broadcasting.
One subtlety worth internalizing: a dropped connection is not a cancellation. The spec says disconnection "SHOULD NOT be interpreted as the client cancelling its request"; cancellation is explicit, via a CancelledNotification. Your long-running tool call should keep running through a network blip.
Sessions, resumability, and the 404 that means "start over"
Statefulness over stateless HTTP needs a correlation token, and MCP's is the Mcp-Session-Id header. A server may assign one on the HTTP response carrying the InitializeResult; if it does, the client must echo it on every subsequent request. The ID must contain only visible ASCII (0x21–0x7E) and should be cryptographically secure. Requests missing a required session ID get 400.
The interesting status code is 404: a server may terminate a session at any time, after which requests bearing that session ID must get 404 Not Found. The client's mandated reaction is not to retry — it's to send a fresh InitializeRequest with no session ID and rebuild the session from scratch. 404 is the protocol's "your state is gone, re-handshake" signal. Clients that treat it as a transient error loop forever against restarted servers. Clients done with a session should send an HTTP DELETE, though servers may refuse with 405.
Resumability piggybacks on plain SSE machinery: servers may attach an id to each SSE event, globally unique within the session but semantically a per-stream cursor. After a broken connection the client sends GET with Last-Event-ID, and the server may replay the messages that would have gone out on that disconnected stream — and must not replay messages belonging to other streams. Message delivery across reconnects is thus opt-in on both sides, not guaranteed.
Two final gotchas from the spec's fine print:
- After initialization, HTTP clients must send an
MCP-Protocol-Versionheader on every request. A server that doesn't receive one should assume2025-03-26— so a client speaking a newer revision but forgetting the header gets silently downgraded semantics. - Servers must validate the
Originheader on all incoming connections. This isn't boilerplate: a local MCP server bound to0.0.0.0without Origin checks is reachable from any web page via DNS rebinding, handing arbitrary websites a JSON-RPC interface to whatever tools it exposes. Bind to127.0.0.1, check Origin, authenticate.
Which transport, then?
The spec itself states that clients should support stdio whenever possible, and for anything that runs on the user's machine it's the right default: process lifetime is session lifetime, the OS gives you isolation and cleanup for free, and there's no Origin, auth, or session plumbing to get wrong. Streamable HTTP earns its complexity exactly when the server outlives any one client or lives on another machine — and then you inherit the full checklist above: dual content-type handling, session IDs, 404-means-reinitialize, protocol version headers, and Origin validation. Most "MCP is flaky" reports are one of these rules, skipped.
