MCP transports in 2026: stdio and Streamable HTTP

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

  1. 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.
  2. On stdio, stdout carries protocol messages and nothing else. Logs go to stderr, and credentials come from the environment.
  3. 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.
  4. 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:

stdioStreamable HTTP
Typical deploymentA local process launched by the hostA remote service, in the cloud or on premises
ConnectionOne process, stdin and stdout pipesIndependent HTTP POSTs
AuthorizationCredentials from the environmentOAuth 2.1 bearer tokens on every request, where authorization is used
Cancellationnotifications/cancelled with the request IDClose the response stream
Notification streamsMultiplexed on the one channel, tagged with subscriptionIdA subscriptions/listen POST with a long-lived response stream
LoggingWrite to stderr, never stdoutOpenTelemetry 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.

Developer machineHostClaude Desktop, VS Code, Claude Code, Gemini CLIConfig: command, args, env.mcp.json, mcp.json, settingsServer processsigned binary or packageOptional sandboxfilesystem and network allowlistsPackage registry / MCP RegistryRemote APIskeys from envspawn, stdioinstall

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:

MCP serverGateway / WAFClientIf the stream breaks before the result,the request is lost and must be re-issuedPOST /mcpAuthorization Bearer tokenMCP-Protocol-Version 2026-07-28Mcp-Method tools/callMcp-Name search_docsroute and apply policy from headers onlyforward requestverify headers match body (else -32020)SSE event notifications/progress (25%)SSE event notifications/progress (80%)SSE event final JSON-RPC result (resultType complete)

The 2026-07-28 revision drops three things from Streamable HTTP:

  • Sessions: the Mcp-Session-Id header 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

HeaderSent onWhat it carries
MCP-Protocol-VersionEvery requestThe protocol version, such as 2026-07-28
Mcp-MethodEvery requestThe JSON-RPC method, such as tools/call
Mcp-Nametools/call, prompts/get and resources/readThe tool or prompt name, or the resource’s Uniform Resource Identifier (URI)
Mcp-Param-{name}tools/call, for a parameter marked with x-mcp-headerThat argument’s value
AuthorizationEvery request to a server that uses OAuthBearer <token>

With these, a gateway can apply per-method and per-tool policy, or route on a value such as a region:

Edge / gatewayMCP clientWAFblock Mcp-Name delete_* for non-adminsRoute byMcp-Param-RegionMCP serverus-west1MCP servereurope-west1POST /mcp + headersus-west1europe-west1

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 mcp 2.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
  1. What an MCP server does in 2026: much more than a list of tools
  2. MCP goes stateless: what changed in the 2026-07-28 specification
  3. server/discover: the one method every MCP server must implement
  4. Server instructions: the paragraph every model reads first
  5. Stateless MCP requests: _meta, resultType and explicit handles
  6. Caching hints and pagination in MCP
  7. MCP transports in 2026: stdio and Streamable HTTP

I’m Amir Pournasserian. I build AI and platform systems for a living, maintain FluentCMS and YeSvelte, and write here about what I find along the way.