technique
Transports: stdio and Streamable HTTP
The two ways messages travel: a local child process over stdin and stdout, or a remote endpoint over HTTP POST with optional streamed responses, plus long-lived subscriptions.
Before this
This page assumes you are comfortable with:
- techniqueAnatomy of an MCP requestHost, client, and server roles, and what every MCP request and result carries in the stateless 2026-07-28 protocol: method, params, _meta, and resultType.
- prerequisiteProcesses and standard streamsWhat a running program is, and how stdin, stdout, and stderr let one program talk to another through pipes.
- prerequisiteHTTP basicsRequests and responses, methods, headers, status codes, and streamed responses: the parts of HTTP a remote MCP server uses.
Why you need this
An MCP message is a JSON-RPC object. A transport is how that object gets from the client (the host's connection to one server) to the server and back. The 2026-07-28 specification defines two. stdio, where the host (the app the person uses) starts the server as a child process on the same machine, and Streamable HTTP, where the server is a web endpoint that could be anywhere. The messages are identical; the framing, the failure modes, and the security model are not. Choosing and implementing a transport is stage 3 of a server's life.
The idea
stdio
The host launches the server as a child process and keeps it running. Then, from the stdio section of the specification:
- The client writes JSON-RPC requests and notifications to the server's stdin, one message per line. A message must not contain a newline inside it, so it is written as compact JSON.
- The server writes responses and notifications to its stdout, one per line, matched to requests by
id. - The server may write anything it likes to stderr, as logs. The client may show, save, or ignore it, and should not treat it as an error. The server must never write anything but MCP messages to stdout.
- The server never sends requests of its own. When it needs input it returns
input_requiredinstead (see Multi round-trip requests). - To cancel a request, the client sends a
notifications/cancellednotification naming itsid. - To shut down, the client closes the server's stdin and waits; if the server does not exit in reasonable time, it kills the process. A server should exit promptly when stdin reaches end of file.
- If the server crashes, the client restarts it. Because the protocol is stateless, in-flight requests are simply lost and can be sent again.
There are no headers: the protocol version and client capabilities ride in each message's _meta.
Streamable HTTP
The server exposes one URL, the MCP endpoint, for example /mcp, that accepts POST.
- Every client message is its own HTTP POST, whose body is exactly one JSON-RPC request or notification.
- The client sends
Accept: application/json, text/event-stream, because the server may answer either way. - The client copies parts of the body into headers, so that load balancers and gateways can route without parsing JSON:
| Header | Copied from | Required on |
|---|---|---|
MCP-Protocol-Version |
_meta io.modelcontextprotocol/protocolVersion |
every request |
Mcp-Method |
method |
every request |
Mcp-Name |
params.name or params.uri |
tools/call, resources/read, prompts/get |
- The server must check that headers and body agree. A mismatch, or a missing required header, gets
400 Bad Requestwith JSON-RPC error-32020(HeaderMismatch). This closes a gap where a gateway routes on the header while the server acts on the body. - An unsupported protocol version gets
400with anUnsupportedProtocolVersionErrorlisting the versions the server does support. An unknown method gets404with JSON-RPC error-32601. An accepted notification gets202 Acceptedwith no body. - For a request, the server answers with either a single JSON object (
Content-Type: application/json) or an SSE stream (Content-Type: text/event-stream) scoped to that one request. On the stream it may send notifications about that request, such asnotifications/progress, and then the response, which should end the stream. - Closing the stream cancels the request. There is no separate cancel message on HTTP. The server stops work and sends nothing more for it.
- No sessions and no resumption. There is no session ID, and a broken stream cannot be picked up where it left off. If the client still wants the answer, it sends the request again as a new request.
Older servers (2025-03-26 through 2025-11-25) used the same transport name with sessions (an Mcp-Session-Id header), a separate GET stream for server-initiated messages, and resumable streams; a server that speaks only 2026-07-28 answers GET and DELETE with 405 Method Not Allowed and ignores Mcp-Session-Id and Last-Event-ID. The even older HTTP+SSE transport from 2024-11-05 has been deprecated since 2025-03-26.
Hearing about changes: subscriptions/listen
Without a GET stream, how does a client learn that the server's tool list changed? It sends a long-lived subscriptions/listen request with a filter of what it wants to hear about: toolsListChanged, promptsListChanged, resourcesListChanged, or a list of resourceSubscriptions URIs. The server first sends notifications/subscriptions/acknowledged listing what it agreed to, then keeps the response open and sends matching notifications as they happen. Every one carries io.modelcontextprotocol/subscriptionId in _meta, equal to the id of the listen request, so a client with several subscriptions can tell them apart. On HTTP the response is an SSE stream that stays open; servers are encouraged to send a comment line starting with : now and then as a keep-alive. On stdio the notifications share stdout with everything else. Either side ends it the same way it cancels any request. Progress notifications never go on the listen stream; they stay with the request they describe.
Securing a local HTTP server
A server listening on your own machine is reachable by any web page your browser opens, through a trick called DNS rebinding: a malicious site makes its own name point at your machine. The Streamable HTTP section says servers must check the Origin header (the site a browser request came from) and reject a bad one with 403 Forbidden, should listen only on 127.0.0.1 (this machine only) rather than 0.0.0.0 (every network interface) when running locally, and should require authentication.
Choosing
| Question | stdio | Streamable HTTP |
|---|---|---|
| Where does the server run? | Same machine as the host | Anywhere reachable |
| Who starts it? | The host, per person | You, once, for everyone |
| Credentials | Environment variables | OAuth tokens on each request (see Authorization) |
| Many users at once? | No, one process per host | Yes |
| Scales by | Nothing to scale | Adding copies behind a load balancer |
| Simplest to build and debug | Yes | Needs a web server, TLS, auth |
Start with stdio for a tool that reads local files or runs local commands. Use HTTP when the server wraps a shared service, when many people use it, or when the host cannot run programs (a browser, a phone).
Worked example
One server, one tools/call, on both transports. The server, using the official Python SDK, runs on stdio by default or on HTTP when given http:
# http_server.py
import asyncio
import sys
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
server = MCPServer("adder")
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
@server.tool()
async def slow_add(a: int, b: int, ctx: Context) -> int:
"""Add two integers, slowly, reporting progress."""
for step in (1, 2):
await ctx.report_progress(step, 2)
await asyncio.sleep(0.5)
return a + b
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "http":
server.run("streamable-http", host="127.0.0.1", port=8765, json_response="--json" in sys.argv)
else:
server.run() # stdio
For stdio, a client written by hand launches the server and writes one line:
# raw_stdio.py
import json
import os
import subprocess
import sys
here = os.path.dirname(os.path.abspath(__file__))
child = subprocess.Popen(
[sys.executable, os.path.join(here, "http_server.py")],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "add",
"arguments": {"a": 2, "b": 3},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "raw-pipe", "version": "0.1"},
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}
line = json.dumps(request, separators=(",", ":")) + "\n"
print("wrote:", line, end="")
child.stdin.write(line)
child.stdin.flush()
print("read: ", child.stdout.readline(), end="")
child.stdin.close()
print("exit code:", child.wait(timeout=10))
print("stderr:", repr(child.stderr.read()))
For HTTP, start python http_server.py http in one terminal, then send hand-written requests from another:
# raw_http.py
import json
import socket
import sys
BODY = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "add",
"arguments": {"a": 2, "b": 3},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "raw-socket", "version": "0.1"},
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}
def send(label, method="POST", headers=None, body=BODY):
data = json.dumps(body, separators=(",", ":")).encode() if body is not None else b""
base = {
"Host": "127.0.0.1:8765",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2026-07-28",
"Mcp-Method": "tools/call",
"Mcp-Name": "add",
"Content-Length": str(len(data)),
"Connection": "close",
}
for k, v in (headers or {}).items():
if v is None:
base.pop(k, None)
else:
base[k] = v
head = f"{method} /mcp HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in base.items()) + "\r\n"
request = head.encode() + data
if label == "ok":
print("----- request bytes -----")
print(request.decode())
with socket.create_connection(("127.0.0.1", 8765)) as s:
s.sendall(request)
chunks = []
while chunk := s.recv(65536):
chunks.append(chunk)
print(f"----- response: {label} -----")
print(b"".join(chunks).decode())
if "--sse" in sys.argv:
slow = json.loads(json.dumps(BODY))
slow["params"]["name"] = "slow_add"
slow["params"]["_meta"]["progressToken"] = "p1"
send("sse", headers={"Mcp-Name": "slow_add"}, body=slow)
sys.exit()
send("ok")
if "--all" in sys.argv:
send("name mismatch", headers={"Mcp-Name": "subtract"})
send("missing Mcp-Name", headers={"Mcp-Name": None})
send("bad origin", headers={"Origin": "http://evil.example"})
send("GET", method="GET", body=None)
Side by side, the same call (date and server response headers omitted):
stdio (python raw_stdio.py) |
Streamable HTTP (python raw_http.py) |
|
|---|---|---|
| Client sends | one line on the server's stdin | POST /mcp HTTP/1.1 |
| Routing information | inside the JSON only | also in MCP-Protocol-Version: 2026-07-28, Mcp-Method: tools/call, Mcp-Name: add |
| Body | the JSON-RPC request | the same JSON-RPC request, with Content-Length: 284 |
| Server answers | one line on its stdout | HTTP/1.1 200 OK, content-type: application/json, content-length: 224 |
| Answer body | identical JSON on both | identical JSON on both |
| Afterwards | stdin closed, exit code: 0, stderr: '' |
connection closed |
The answer body on both transports:
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":5},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"adder","version":""}}}}
Now python raw_http.py --sse calls slow_add with a progressToken in _meta, asking for progress. This time the server chose a stream:
HTTP/1.1 200 OK
date: Fri, 02 Oct 2026 21:44:43 GMT
server: uvicorn
content-type: text/event-stream
cache-control: no-cache, no-transform
x-accel-buffering: no
Transfer-Encoding: chunked
Connection: close
84
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":1,"total":2}}
84
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":2,"total":2}}
fa
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":5},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"adder","version":""}}}}
0
Two progress events half a second apart, then the result, then the stream ends (84, fa, and 0 are chunk lengths in hexadecimal). With no progress requested, the same SDK answered add with plain JSON.
Finally, python raw_http.py --all sends four broken requests. The JSON bodies of the responses:
| Request | Status | Body |
|---|---|---|
Mcp-Name: subtract but body says add |
400 Bad Request |
{"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"mcp-name header does not match the request body's 'name' parameter"}} |
Mcp-Name left out |
400 Bad Request |
the same -32020 error |
Origin header naming another site |
403 Forbidden |
Invalid Origin header |
GET /mcp |
405 Method Not Allowed, with allow: POST |
empty |
The SDK's client hides all of this. In a separate check, a Client given StdioServerParameters that launch http_server.py, and a Client given the string "http://127.0.0.1:8765/mcp", both returned {'result': 5} for the same call, each reporting protocol version 2026-07-28.
In a server's life
- Build and connect (stage 3). Pick the transport; Building a server in Python runs on stdio and Building a client connects over both.
- Secure it (stage 4). stdio inherits the user's permissions and environment; HTTP needs Origin checks, TLS, and OAuth (Authorization for remote servers).
- Test and ship (stage 5). HTTP's statelessness is what lets Deploying remote servers put many copies behind a load balancer.
- Maintain it (stage 6).
subscriptions/listenis how clients hear that tools changed; see Evolving a server without breaking clients.
Common mistakes
print()in a stdio server. One debug line on stdout and the client fails to parse a message, or hangs waiting for one. Log to stderr.- Pretty-printed JSON on stdio. A message spread over several lines is read as several broken messages. Write compact JSON, one per line.
- Listening on 0.0.0.0 for a local server. Other machines on the same network may be able to call your tools. Bind to 127.0.0.1; the SDK's
run("streamable-http")defaults to it and, for a localhost address, turns on the Origin check that produced the 403 above. - Headers that disagree with the body. A client that builds headers once and then edits the body gets
400and-32020. Build headers from the final body. - A proxy that buffers SSE. Progress arrives all at once at the end. Keep
X-Accel-Buffering: noand check proxy settings. - Expecting a dropped stream to resume. The work was cancelled when the stream closed. Send the request again, and design tools so a repeat is safe.
Cost
stdio costs one process per host per server, started once (tens of milliseconds for Python, more as it imports libraries), then very little per message: no network, no headers, no TLS. Streamable HTTP costs a network round trip per request plus a few hundred bytes of headers (about 230 bytes of request headers above), and a TLS handshake for each new connection in production. An SSE stream holds a connection and server memory for as long as the request runs, and a subscriptions/listen stream holds one for as long as the client stays subscribed, so a server with subscribed clients holds open connections. In exchange HTTP serves many people from one deployment and scales by adding copies. Engineering cost is lower for stdio (no web server, no auth) and higher for HTTP (TLS, OAuth, Origin checks, proxies that must not buffer).
Going further
- The Transports, stdio, Streamable HTTP, Subscriptions, and Cancellation sections of the 2026-07-28 specification.
- The
x-mcp-headerextension, which copies chosen tool arguments intoMcp-Param-headers, and the Base64 encoding used when a header value is not plain ASCII. - Backward compatibility: how a client probes with
server/discoverand falls back to an older handshake; see Version negotiation. - DNS rebinding attacks and why browsers send an
Originheader.
Leads to
- techniqueAuthorization for remote serversHow a remote MCP server decides who may call it: OAuth 2.1 with the server as a resource server, discovery of the authorization server, client registration, and token checks.
- techniqueBuilding a clientThe other side of the wire in Python: connecting to a server, discovering what it offers, calling tools, handling input_required, and caching list results.
- techniqueDeploying remote serversRunning a server somewhere other than a laptop: why the stateless protocol suits serverless and load-balanced hosting, where state lives instead, and long-running work.