Start here: the basics
MCP stdio vs Streamable HTTP: what is the difference?
Stdio talks to a server over its own stdin and stdout, Streamable HTTP over an HTTP endpoint, and this page shows what each one sends.
Stdio and Streamable HTTP are the two standard transports in the MCP spec. Stdio launches the server as a subprocess and exchanges newline-delimited JSON-RPC over its standard input and output. Streamable HTTP talks to a server that already runs as its own process, sending each request as an HTTP POST and getting back either a plain JSON reply or a Server-Sent Events stream.
Below, both run against the same small server, with the lines each one sends.
The server under test
The server is six lines on top of the Python SDK's MCPServer class, with one tool:
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + bmcp.run(transport="stdio") runs it over stdio. mcp.run(transport="streamable-http") runs the same server as an HTTP service on 127.0.0.1:8000. Same tool, same tools/list and tools/call methods, two different ways to reach it.
The stdio exchange
Over stdio, a client writes a JSON-RPC request as one line and reads a JSON-RPC response as one line back, over the two streams a subprocess already has. There is no HTTP and no headers. Launching the server and sending three requests by hand produces this exchange (>>> is what the client wrote to the server's stdin, <<< is what the server wrote to its stdout):
>>> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "demo-client", "version": "0.1"}}}
<<< {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"protocolVersion":"2025-11-25","serverInfo":{"name":"demo","version":""}}}
>>> {"jsonrpc": "2.0", "method": "notifications/initialized"}
>>> {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
<<< {"jsonrpc":"2.0","id":2,"result":{"tools":[{"description":"Add two integers.","inputSchema":{"properties":{"a":{"title":"A","type":"integer"},"b":{"title":"B","type":"integer"}},"required":["a","b"],"type":"object","title":"addArguments"},"name":"add","outputSchema":{"properties":{"result":{"title":"Result","type":"integer"}},"required":["result"],"type":"object","title":"addOutput"}}]}}
>>> {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "add", "arguments": {"a": 2, "b": 3}}}
<<< {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"structuredContent":{"result":5}}}Nothing here is MCP-specific past the method names. It is JSON-RPC on two file descriptors. Whoever started the process is the only party that can write to its stdin, so there is no second caller to authenticate and no network address to protect.
The Streamable HTTP exchange
The same server, started with transport="streamable-http", listens on http://127.0.0.1:8000/mcp. A real curl POST to initialize:
curl -i -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-11-25" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"demo-client","version":"0.1"}}}'returned:
HTTP/1.1 200 OK
cache-control: no-cache, no-transform
content-type: text/event-stream
mcp-session-id: 758169f52d974de99ad848fc2b0ca6a3
x-accel-buffering: no
Transfer-Encoding: chunked
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"protocolVersion":"2025-11-25","serverInfo":{"name":"demo","version":""}}}The server chose to answer with an SSE stream rather than one JSON object, and it minted a session ID in Mcp-Session-Id. Every later request on that session carries the same header, and tools/call with that header produces the same result shape as the stdio run, just wrapped in an event: message / data: SSE frame instead of a bare line.
Two things the Streamable HTTP server enforces that stdio has no reason to: it rejects a request missing the MCP-Protocol-Version header, and, tried with a spoofed Origin: https://evil.example.com header, it answered 403 Forbidden with Invalid Origin header, before the request body was touched.
What the 2026-07-28 revision changed
Everything above matches protocol revision 2025-11-25, which the Python SDK still negotiates by default: initialize, a session ID, an SSE stream the server can push follow-up notifications on. The current spec revision, 2026-07-28, removes that model for Streamable HTTP. There is no Mcp-Session-Id header, no initialize handshake, and no standalone GET to open a server-pushed stream. Each tools/call is a self-contained POST that carries its own protocol version and capabilities, and a server that needs input mid-call (sampling, elicitation, listing roots) returns an InputRequiredResult the client answers and resubmits, instead of the server sending its own JSON-RPC request on the open stream. Resumable streams via Last-Event-ID are gone too: the spec now says plainly that they "are not supported."
The spec's own backward-compatibility page spells out how a server tells the two eras apart, and the same page is explicit that the still-older HTTP+SSE transport, from protocol version 2024-11-05 with separate SSE and POST endpoints, is "deprecated." It stays documented only so a 2026-07-28 server can keep answering a client that never updated.
None of this changes stdio. Stdio was never session-scoped, so the 2026-07-28 transports page describes it in almost the same terms as every revision before it: a client-launched subprocess, newline-delimited JSON-RPC, nothing to negotiate about state because there is only ever one client.
If you build or run a server today, the Python SDK installed here (mcp 2.3.0) is a useful data point: its HANDSHAKE_PROTOCOL_VERSIONS constant tops out at 2025-11-25, and 2026-07-28 sits in a separate MODERN_PROTOCOL_VERSIONS tuple reached a different way.
Security beyond the transport choice
The security rules in the spec attach to Streamable HTTP, not to stdio, because only Streamable HTTP has a network listener. It MUST validate the Origin header on every request, which the 403 above shows a conforming implementation doing. If the server is meant to be reached only from the same machine, it SHOULD bind to 127.0.0.1 rather than 0.0.0.0, which is also what the demo server did by default. And it SHOULD authenticate connections rather than trust the network path alone. Skip these and a malicious web page can use DNS rebinding to make a browser talk to a Streamable HTTP server that was only ever meant to answer the one client on that machine.
None of the three apply to stdio: there is no listener to rebind to and no second caller to authenticate.
For a remote server, one way to meet the authentication rule is a gateway that checks a token on every call. Statio does this, on its managed cloud or as a self-hosted MCP gateway.
For which one to run, and the credential, isolation and audit tradeoffs that come with it, see Local vs remote MCP servers: which should you run?.
Next
- Local vs remote MCP servers: which should you run?, the fuller tradeoff behind this transport choice.
- How to build an MCP server in Python, for building one past this six-line example.
- MCP vs APIs: what is the difference?, for what
tools/listandtools/callreplace. - How to test an MCP server with the MCP Inspector, a GUI for the same requests shown here.
- How to debug an MCP server that won't connect, when one of these exchanges fails instead.
Questions
tools/list couldn't be cached between sessions. Servers that need state across calls now return an ID from one tool, such as a basket_id, and take it as an argument on later calls.