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.

By
Dan Flanagan
Updated
October 15, 2026
Tested with
mcp (Python SDK) 2.3.0 via uv, Python 3.12.5, curl 8.7.1, macOS, October 2026
Reading time
5 min

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:

python
from mcp.server.mcpserver import MCPServer

mcp = MCPServer("demo")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

mcp.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

Questions

Stdio for a server one client runs locally. Streamable HTTP for a server reachable over a network by more than one caller. Most vendor-hosted MCP servers use Streamable HTTP; most local tool servers use stdio.

The spec calls it deprecated. A current server can still host it alongside the modern endpoint for an old client, and a current client can still detect and fall back to it, but neither is required to.

No. A server can answer a request with one JSON object instead of opening a stream. SSE is for when the server wants to send progress notifications before the final response, or, in revisions through 2025-11-25, a longer-lived connection.

SEP-2567, the proposal that removed them, says sessions never settled on one meaning across clients: some clients scoped a session to one tool call, some to an app launch, and almost none resumed them, so server authors couldn't know how long session state would live. It also meant 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.

Yes. The transport is a property of each connection, not of the client, so a client commonly runs a local stdio server for filesystem access alongside a remote Streamable HTTP server for a SaaS tool.