MCP transports in 2026: stdio and Streamable HTTP

What surprised me in the 2026-07-28 transport rules is how much is now mirrored into HTTP headers: a gateway in front of a Model Context Protocol (MCP) server can see which method and tool a request is for without reading its body. The failures to watch for are small: a stray line on stdout, or a proxy that cuts a quiet call.
This is part 7 of my series on what an MCP server does under the 2026-07-28 specification, after caching hints and pagination in part 6. The facts are as I read them in October 2026.
In brief
- MCP has two standard transports for its JSON-RPC 2.0 messages: stdio, typically for a local process the host launches, and Streamable HTTP, typically for a remote service.
- On stdio, stdout carries protocol messages and nothing else. Logs go to stderr, and credentials come from the environment.
- On Streamable HTTP, headers say what each request is, so a gateway can act on them, and a header that disagrees with the body gets
-32020. - A broken response stream loses its request, so as I see it, proxy idle timeouts are the main operational risk.
Two transports
Side by side:
| stdio | Streamable HTTP | |
|---|---|---|
| Typical deployment | A local process launched by the host | A remote service, in the cloud or on premises |
| Connection | One process, stdin and stdout pipes | Independent HTTP POSTs |
| Authorization | Credentials from the environment | OAuth 2.1 bearer tokens on every request, where authorization is used |
| Cancellation | notifications/cancelled with the request ID | Close the response stream |
| Notification streams | Multiplexed on the one channel, tagged with subscriptionId | A subscriptions/listen POST with a long-lived response stream |
| Logging | Write to stderr, never stdout | OpenTelemetry or your platform’s logging |
A third, older transport, HTTP+SSE (HTTP with Server-Sent Events, or SSE), has been deprecated since 2025-03-26; part 2 has its removal timeline.
stdio: stdout belongs to the protocol
The host launches your server as a child process and exchanges JSON-RPC messages over its stdin and stdout. The rules:
- Only protocol messages go to stdout. A stray print statement corrupts the stream.
- Credentials come from the environment. Implementations using stdio SHOULD NOT follow the authorization specification.
In Python, log with the logging module: the official Python software development kit (SDK), mcp 2.3.0, routes its output to stderr once you create an MCPServer:
import logging
from mcp.server import MCPServer
mcp = MCPServer("docs") # also sets up logging on stderr, at INFO
log = logging.getLogger("docs")
@mcp.tool()
def search_docs(query: str) -> str:
"""Search the documentation."""
log.info("search_docs query=%r", query) # stderr, never stdout
return f"No results for {query!r}"
if __name__ == "__main__":
mcp.run() # stdio by default: stdout carries the protocol
Under the SDK’s client over stdio, the call returned and the log line went to stderr.
The SDK points stdout at stderr while it serves stdio, a safety net I wouldn’t lean on: in my test on Windows, an unflushed print() sat in Python’s buffer until the server stopped, then reached the client as a line it couldn’t parse.
Shipping a local server
A local server is simple to operate but runs arbitrary code on the user’s machine, so its risks are the supply chain and trust. My advice: publish signed, versioned packages, keep dependencies minimal, read secrets from the environment, and support the hosts’ sandboxing. VS Code, for example, can sandbox stdio servers on macOS and Linux as of October 2026, with filesystem and network allowlists.
Streamable HTTP: one POST per message
The client sends each JSON-RPC message as an HTTP POST to the server’s single MCP endpoint. The server answers with one JSON response, or an SSE stream that carries request-scoped notifications, such as progress, then the final result. Here a gateway or Web Application Firewall (WAF) decides from the headers alone:
The 2026-07-28 revision drops three things from Streamable HTTP:
- Sessions: the
Mcp-Session-Idheader is gone; part 5 shows where state goes instead. - The GET endpoint: notifications move to
subscriptions/listen. - Resumability: a broken response stream loses the request in flight, and the client MUST re-issue it with a new ID.
The request headers
| Header | Sent on | What it carries |
|---|---|---|
MCP-Protocol-Version | Every request | The protocol version, such as 2026-07-28 |
Mcp-Method | Every request | The JSON-RPC method, such as tools/call |
Mcp-Name | tools/call, prompts/get and resources/read | The tool or prompt name, or the resource’s Uniform Resource Identifier (URI) |
Mcp-Param-{name} | tools/call, for a parameter marked with x-mcp-header | That argument’s value |
Authorization | Every request to a server that uses OAuth | Bearer <token> |
With these, a gateway can apply per-method and per-tool policy, or route on a value such as a region:
A header that disagrees with the body gets HeaderMismatchError (-32020). Without that check, an attacker could slip past header-based policy by naming one tool in the header and another in the body.
Mirroring a parameter into a header
A tool marks a parameter by adding "x-mcp-header": "Region" to that property in its inputSchema, and the client then sends Mcp-Param-Region: us-west1. The rules for the annotation:
- Its value, the header name, is a valid HTTP header token, non-empty and unique within the schema, ignoring case.
- It sits on a string, integer or boolean parameter, never
number. - That parameter is statically reachable from the schema root.
A client MUST drop a tool that breaks these rules. Servers SHOULD NOT mark sensitive parameters, such as passwords, keys, tokens or personally identifiable information, because intermediaries can see header values.
In Python, the annotation goes in Pydantic’s Field, and the SDK’s in-memory Client shows the schema a client receives:
import json
from typing import Annotated
import anyio
from mcp import Client
from mcp.server import MCPServer
from pydantic import Field
mcp = MCPServer("docs")
@mcp.tool()
def search_docs(
query: str,
region: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Region"})],
) -> str:
"""Search the documentation served from one region."""
return f"No results for {query!r} in {region}"
async def main():
async with Client(mcp) as client:
tools = await client.list_tools()
print(json.dumps(tools.tools[0].input_schema, indent=2))
if __name__ == "__main__":
anyio.run(main)
It printed this, with the annotation on region:
{
"type": "object",
"properties": {
"query": {
"title": "Query",
"type": "string"
},
"region": {
"title": "Region",
"type": "string",
"x-mcp-header": "Region"
}
},
"required": [
"query",
"region"
],
"title": "search_docsArguments"
}
MCPServer checks the rules at registration: a float parameter, or a header name with a space, raises InvalidSignature. Over Streamable HTTP, my call with region set to us-west1 carried these MCP headers (lower-cased by the server):
mcp-protocol-version: 2026-07-28
mcp-method: tools/call
mcp-name: search_docs
mcp-param-region: us-west1
The client’s server/discover and tools/list POSTs carried only the first two. A header I sent that disagreed with the body got -32020 and HTTP 400.
Proxies, long calls and local HTTP servers
On Streamable HTTP, the main operational risk I see is a proxy or load balancer with an idle timeout. A long tool call that sends nothing for 60 seconds can be cut by an intermediary, and since the stream can’t be resumed, the work is lost. My advice:
- Send progress notifications as the work runs, so the stream doesn’t go quiet. They don’t buy unlimited time: the cancellation page says a client MAY reset its timeout clock on progress but SHOULD still enforce a maximum.
- Set intermediary idle timeouts above your longest expected call.
- Move anything that runs longer than about a minute to Tasks, covered in a later part. That minute is my rule of thumb. The Tasks overview sets the bar lower: it says that many clients and transport intermediaries impose timeouts that make blocking impractical beyond a few seconds.
A local server on HTTP
Treat it as a remote server that happens to be close: bind it to localhost only, validate the Origin header so a malicious web page can’t reach it through Domain Name System (DNS) rebinding, and still require authentication for sensitive data. The Python SDK does the first two by default: mcp.run("streamable-http") binds to 127.0.0.1 and checks Host and Origin, and my POST with Origin: http://evil.example got a 403.
Method and caveats
- Built from my guide’s transports chapter and the local stdio variant of its reference architecture, written against the 2026-07-28 specification.
- Both Python samples, and small variants of them, ran against
mcp2.3.0 on Windows on 8 October 2026; every output and result in the text comes from those runs. - Where the Streamable HTTP page is more precise than my notes (which headers each request carries), this article and its sequence diagram follow it.
- The one-minute line for Tasks is my rule of thumb; the Tasks overview and the cancellation page were checked on 8 October 2026.
SeriesWhat an MCP server actually does in 2026Part 7 of 35
- What an MCP server does in 2026: much more than a list of tools
- MCP goes stateless: what changed in the 2026-07-28 specification
- server/discover: the one method every MCP server must implement
- Server instructions: the paragraph every model reads first
- Stateless MCP requests: _meta, resultType and explicit handles
- Caching hints and pagination in MCP
- MCP transports in 2026: stdio and Streamable HTTP