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:

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_required instead (see Multi round-trip requests).
  • To cancel a request, the client sends a notifications/cancelled notification naming its id.
  • 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 Request with 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 400 with an UnsupportedProtocolVersionError listing the versions the server does support. An unknown method gets 404 with JSON-RPC error -32601. An accepted notification gets 202 Accepted with 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 as notifications/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

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 400 and -32020. Build headers from the final body.
  • A proxy that buffers SSE. Progress arrives all at once at the end. Keep X-Accel-Buffering: no and 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 CC subscribed clients holds CC 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-header extension, which copies chosen tool arguments into Mcp-Param- headers, and the Base64 encoding used when a header value is not plain ASCII.
  • Backward compatibility: how a client probes with server/discover and falls back to an older handshake; see Version negotiation.
  • DNS rebinding attacks and why browsers send an Origin header.

Leads to

Back to Building and maintaining MCP servers