Transports - stdio and Streamable HTTP
This document explains the specifications of MCP's transport layer (stdio and Streamable HTTP) and what to watch for in production. The contents of messages (_meta, resultType, and so on) are covered in MCP Protocol Basics; here we focus on the layer that carries them. For the list of changes in 2026-07-28 and how to migrate, see MCP 2026-07-28 Spec Changes and Migration. For safe operation of local servers, see Operating Local MCP Servers Safely.
1. What This Document Covers
- The stdio transport
- The Streamable HTTP transport
- Required HTTP headers
- Working with gateways / WAFs
- Subscriptions (subscriptions/listen)
- Behavior when a stream is disconnected
- Stateless horizontal scaling
- Migrating from HTTP+SSE
- Connecting from a browser
A Transport Is a "Binding"
The meaning of the protocol is the same on every transport. A transport is a "binding" that defines how messages are delimited and delivered, how request metadata is carried, and how cancellation and termination are signaled. It does not define what messages mean. The standard transports are these two.
- stdio: the client launches a subprocess and exchanges newline-delimited messages over its standard streams
- Streamable HTTP: every message is sent as an HTTP POST to a single MCP endpoint, and the response arrives as a JSON object or a request-scoped SSE stream
Message direction is the same on both. Client requests and notifications go to the server, and server responses and notifications go to the client. The server never sends JSON-RPC requests, and the client never sends JSON-RPC responses. You can also build your own transport, but you must preserve the JSON-RPC message format, the message patterns, and the per-request metadata model. If you build on a reliable bidirectional byte stream (a Unix domain socket or TCP), reusing the same framing as stdio is recommended.
The stdio Transport
In stdio, the client launches the MCP server as a subprocess.
- The server reads JSON-RPC messages from
stdinand writes them tostdout - One message is one line, and a message must not contain embedded newlines
- The server must not write anything to
stdoutthat is not a valid MCP message. It may write logs tostderras UTF-8 strings - The client may capture, forward, or ignore
stderr, and must not takestderroutput to indicate an error - The client must not write anything to
stdinthat is not a valid MCP message
There are no per-request streams on the standard streams; all messages share one channel. The server writes three kinds of messages to stdout.
- Responses to client requests (matched by the JSON-RPC
id) - Notifications related to an in-flight request (
notifications/progressandnotifications/message) - Notifications delivered for an active
subscriptions/listen. The client matches them to a subscription withio.modelcontextprotocol/subscriptionIdin_meta
All request metadata goes in the message body (_meta). There is no header layer.
Cancellation: the client sends notifications/cancelled containing the request's ID. The server stops work as soon as practical and must not send any further messages for that request.
Shutdown: the client (1) closes the input stream to the server, (2) waits for the server to exit, and (3) if it does not exit in a reasonable time, forcibly terminates it by the method appropriate for the OS. On POSIX this typically escalates from SIGTERM to SIGKILL. The server is recommended to exit promptly when standard input is closed (EOF is read). This is the only portable shutdown signal.
If the server process exits unexpectedly, the client is recommended to restart it. Because the protocol is stateless, in-flight requests are simply lost and can be retried against the new process. A subscriptions/listen that was active has to be reopened after the restart.
Authentication for stdio does not follow the OAuth specification; credentials are obtained from the environment. A stdio server is an ordinary OS process that runs with the privileges of the user who launched it. Its threat model is covered in Operating Local MCP Servers Safely.
The Streamable HTTP Transport
In Streamable HTTP, the server is an independent process that can handle multiple client connections.
- The server must provide a single HTTP endpoint that accepts POST (the MCP endpoint, e.g.
https://example.com/mcp) - The client sends each JSON-RPC request and notification as its own HTTP POST
- The server answers each request with either a single JSON object (
Content-Type: application/json) or an SSE stream scoped to that request (Content-Type: text/event-stream) - Server-initiated asks (Sampling, Elicitation, Roots) are carried as input requests inside results (MRTR)
- Long-lived change notifications are delivered on the response stream of
subscriptions/listen
The client's rules are as follows.
- Send every JSON-RPC message as a new HTTP POST
- Include both
application/jsonandtext/event-streamin theAcceptheader. It must be able to handle either form of response - The POST body is a single JSON-RPC request or notification, and never a response
- If the server accepts a notification, it returns
202 Accepted(no body); if it cannot, it returns an HTTP error (for example400)
When it responds with an SSE stream, the server can send notifications related to the originating request (such as progress) before the final response. It must not send independent JSON-RPC requests on that stream. The final response is recommended to end the stream.
Cancellation: in Streamable HTTP, cancellation is closing the SSE response stream. Because each request has its own stream, it is unambiguous which request a disconnect cancels. The server treats the disconnect as cancellation of that request, stops work as soon as practical, and must not send any further messages. In this version of the core protocol, notifications/cancelled is not expected over HTTP.
Security requirements are these three.
- The server must validate the
Originheader on all connections (as a countermeasure to DNS rebinding). IfOriginis present and invalid, it returns403 Forbidden - A server running locally is recommended to bind only to localhost (127.0.0.1) rather than all interfaces (0.0.0.0)
- The server is recommended to implement proper authentication for all connections. For HTTP authorization, see MCP Authorization - OAuth 2.1 and Metadata Discovery
Required HTTP Headers
Streamable HTTP mirrors some body fields into HTTP headers, so that load balancers, gateways, and observability tools can route and inspect requests without parsing the body. The body is the source of truth, and the headers are copies.
| Header | Source | Required for |
|---|---|---|
MCP-Protocol-Version | io.modelcontextprotocol/protocolVersion in _meta | All POSTs |
Mcp-Method | method | All requests |
Mcp-Name | params.name or params.uri | tools/call, resources/read, prompts/get |
Mcp-Param-{Name} | Tool argument (the one designated with x-mcp-header) | When the designated parameter has a value |
Header names are case-insensitive. Values (such as method names) are case-sensitive.
The value of MCP-Protocol-Version must match the value in the body's _meta. If it does not, the server rejects the request with 400 Bad Request and HeaderMismatch. If the server does not support the requested version, it also returns 400 Bad Request, with UnsupportedProtocolVersionError listing the versions it supports. If the server does not implement the requested RPC method, it returns 404 Not Found with the JSON-RPC error code -32601 (Method not found). This body is a clue for telling it apart from the 404 of a legacy HTTP+SSE server that does not host the modern MCP endpoint.
The value of Mcp-Name, if it cannot be represented as a safe ASCII string (non-ASCII, control characters, leading or trailing whitespace, and so on), is encoded in the Base64 format =?base64?{Base64 value}?=.
x-mcp-header is an extension you attach to a property in a tool's inputSchema. If the value is Region, that parameter's value is also put into the Mcp-Param-Region header. Whether a server uses it is optional, but clients must support the feature. The constraints are as follows.
- The value is non-empty, valid as an HTTP field-name token, contains no control characters, and is case-insensitively unique within the schema
- It can be attached only to properties of primitive type (integer, string, boolean). The
numbertype is not allowed. Integers must be within the JavaScript safe range - It can be attached only to properties reachable from the schema root by following only
propertieskeys. Anything throughitems,oneOf/anyOf/allOf,if/then/else, or$refis not allowed - An HTTP client excludes a tool definition that violates the constraints from the result of
tools/listand logs a warning, so that one malformed definition does not make the other tools unusable
Values are encoded as follows: a string as is, an integer in decimal, and a boolean as lowercase true / false. A value that contains non-ASCII or control characters, has leading or trailing whitespace, or starts with =?base64? and ends with ?= is encoded in UTF-8 Base64. If the value is null or unspecified, the header is omitted.
Server validation: a server that processes the body must verify that the header value (decoded if necessary) matches the body value. A mismatch, a missing required header, invalid characters, or Base64 that cannot be decoded is rejected with 400 Bad Request and the JSON-RPC error -32020 (HeaderMismatch). This requirement exists so that a situation where "the sources of truth differ", with the load balancer deciding the route by the header while the server executes by the body, cannot be exploited. If a client is rejected because Mcp-Param-* is missing, it is recommended to check for changes to inputSchema with tools/list and retry with the correct headers.
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32020,
"message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
}
}
Working with Gateways / WAFs
Thanks to Mcp-Method and Mcp-Name, you can control traffic per method or per tool without parsing the JSON body.
- Routing: send only requests whose
Mcp-Methodistools/callto the backend for tool execution - Rate limiting: set limits per
Mcp-Name(tool name). Be strict only for heavy tools - Observability: use the method and tool name as labels in access logs and metrics
- Authorization support: allow write-type tools only from certain networks
- Routing by region or tenant: route on
Mcp-Param-RegionorMcp-Param-TenantId, designated withx-mcp-header, without parsing the body
However, header values come from tool arguments. They are values that an LLM or a malicious client can influence, so do not make them the only basis for security decisions.
- Values that imply access to a specific resource, such as a tenant ID or region, are checked separately against the authenticated user's permissions
- Do not grant privileges, or skip rate limits and quotas, on the basis of headers alone
- Do not designate passwords, API keys, tokens, or personal information with
x-mcp-header. Headers are visible to intermediaries such as load balancers and logs. Base64 is not encryption - An intermediary that sets policy on header values confirms that
MCP-Protocol-Versionindicates a version that requires header-body validation. If the version is old or the header is absent, it is recommended to reject the request rather than trust unvalidated values - Reject oversized or excessive headers early, before Base64 decoding or body parsing. The specification sets no header size limit; the operator decides it according to the environment
Subscriptions (subscriptions/listen)
subscriptions/listen is a long-lived stream for receiving notifications from the server to the client. It replaces the HTTP GET endpoint and resources/subscribe / resources/unsubscribe.
The client specifies the notification types it wants in notifications. The server must not send types that were not requested.
| Field | Type | Notification received |
|---|---|---|
toolsListChanged | boolean | notifications/tools/list_changed |
promptsListChanged | boolean | notifications/prompts/list_changed |
resourcesListChanged | boolean | notifications/resources/list_changed |
resourceSubscriptions | string[] | notifications/resources/updated for the specified URIs |
All are optional, and omitting one is the same as not subscribing to that type.
- Acknowledgment: the server must send
notifications/subscriptions/acknowledgedas the first message, and must not send any notification before it. Thenotificationsin the acknowledgment is the part the server actually supports, and unsupported types are omitted. The client is recommended to compare it with its request and handle unsupported types gracefully - Identification: every notification carries
io.modelcontextprotocol/subscriptionIdin_meta. The value is the JSON-RPC ID of thesubscriptions/listenrequest. A client can hold multiple subscriptions at the same time and demultiplexes by this value. On stdio, where all messages share one channel, it is required - Scope: request-scoped notifications such as
notifications/progressandnotifications/messageare delivered not on this stream but on the response stream of the request they belong to - Ending: the client closes the SSE stream on HTTP, and on stdio ends it with
notifications/cancelledspecifying the ID of thesubscriptions/listen. When the server ends it on its own, it is recommended to return a successful response to the original request (resultType: "complete") before closing. If the stream closes without this response, it is an unexpected disconnect - Keep-alive: for long streams, the server is recommended to periodically send an SSE comment line (a line starting with a colon), to avoid being cut off by intermediaries or client idle timeouts. The client ignores comment lines
Notification delivery is best effort and can be lost across reconnections. Clients combine it with refetching based on ttlMs.
Behavior When a Stream Is Disconnected
Resumption of SSE streams (Last-Event-ID) and message redelivery have been removed. When a stream breaks, the in-flight request is lost.
- The client must reissue the request with a new request ID
- The server ignores a
Last-Event-IDsent by a client on 2025-11-25 or earlier. The stream cannot be resumed - Design tools to be safe to run again (idempotent). For long-running work, make the result retrievable later through an explicit handle or the Tasks extension
- A
subscriptions/listenis reopened by the client when it breaks. The server holds no subscription state across reconnections. Changes that happened during the reconnection are made up by refetching*/list - On stdio, when the process exits, the client restarts it, retries requests, and reopens subscriptions
Stateless Horizontal Scaling
With no sessions, there is no need to send a request back to the same server.
- Sticky sessions (pinning by session ID or cookie) are unnecessary. You can line up identical servers behind any load balancer
- Every instance can process a request from
_metaand headers alone - State that should be shared among instances is handled through explicit handles issued by tools and shared storage (such as a database)
- State of processing that spans multiple requests is carried in MRTR's
requestStateor in handles of the Tasks extension - An SSE stream is tied to one instance only for the duration of one request. A
subscriptions/listenis held by one instance while it is open. When replacing instances in a deployment, assume the flow in which the server closes the subscription with a successful response and the client reopens it - If a reverse proxy (such as nginx) buffers, SSE events are delayed. A server that returns an SSE stream is recommended to add
X-Accel-Buffering: no
Migrating from HTTP+SSE
The HTTP+SSE transport of the 2024-11-05 version has been deprecated since 2025-03-26, and SEP-2596 also classified it as Deprecated under the feature lifecycle. New implementations do not adopt it and migrate to Streamable HTTP. Under the transition rule, its earliest removal is three months after SEP-2596 reaches Final (removal is a Core Maintainers decision). For the latest timing, check the registry of deprecated features.
The Streamable HTTP of 2025-03-26 through 2025-11-25 also has a different shape from 2026-07-28.
| Item | Up to 2025-11-25 | 2026-07-28 |
|---|---|---|
| Sessions | Mcp-Session-Id (ended with HTTP DELETE) | None |
| GET stream | The client could open it for server-initiated messages | None (subscriptions/listen) |
| Server-initiated requests | Could be sent on SSE streams | None (MRTR) |
| Resumption | Last-Event-ID | None |
When a server that supports only 2026-07-28 receives traffic from an old client, it responds as follows.
- HTTP GET or DELETE to the MCP endpoint:
405 Method Not Allowed Mcp-Session-Idheader: ignore it. Neither issue nor echo session IDsLast-Event-IDheader: ignore it
A client that wants to support old servers first tries a new Streamable HTTP POST. If it fails with one of 400, 404, or 405 and the body is not a recognized modern JSON-RPC error, it sends a GET to the server URL, and if the first event of the SSE stream is endpoint, it judges it to be a legacy HTTP+SSE server. If the body contains a recognized modern error, the server is modern, so it retries with the advertised supported versions or fixes the request.
Connecting from a Browser
Key points when a web page or a client running in a browser connects to an MCP HTTP endpoint.
- Origin validation: the server must validate the
Originheader and reject with403 Forbiddenif it is present and invalid. This is the specification's countermeasure to DNS rebinding through a browser - Localhost servers: listen only on 127.0.0.1, not all interfaces, and implement authentication. Without protection, an attacker can use DNS rebinding to operate a local MCP server from a remote website. For details and an implementation example, see Operating Local MCP Servers Safely
- CORS: the specification does not define CORS configuration. To connect from a browser to a server on another origin, set CORS response headers that explicitly name the allowed origins on the server or gateway. CORS headers are not authentication, so use OAuth or similar together. A POST with custom headers such as
MCP-Protocol-Version,Mcp-Method, andMcp-Nametriggers a preflight (OPTIONS), so include these inAccess-Control-Allow-Headers
2. Comparing stdio and Streamable HTTP
| Aspect | stdio | Streamable HTTP |
|---|---|---|
| Main use | Local tools, integrations on a developer's machine | Remote servers, organization-wide services |
| Authentication | Obtained from the execution environment, such as environment variables (does not follow the OAuth spec) | Follows OAuth 2.1-based authorization |
| Distribution and launch | The client launches and manages the process | Just register a URL. Operated on the server side |
| Scaling | One process per client | Horizontally scalable |
| Observability | Mainly stderr logs | Can reuse existing HTTP monitoring infrastructure |
3. Common Pitfalls for Developers
Writing Logs to stdout
If a stdio server writes print or logs to stdout, it corrupts the protocol messages and the client fails to parse them. stdout is for valid MCP messages only, and logs go to stderr. Also check that the libraries you depend on do not write to stdout.
Putting Newlines in a Message
In stdio, one message is one line. If you write formatted JSON with newlines as is, the delimiting breaks. Serialize JSON on one line. Newlines inside strings are escaped as \n, so they are fine.
Sending Only One Side of the Accept Header
The client must include both application/json and text/event-stream in Accept. The server can choose per request which form to respond with. When testing with curl, specifying only one makes it fail depending on the response form.
Header and Body Mismatches
If MCP-Protocol-Version and the _meta value, Mcp-Method and method, or Mcp-Name and params.name do not match, the request is rejected with 400 and HeaderMismatch (-32020). Check that a gateway or your own client is not rewriting or dropping headers. A missing header is also subject to HeaderMismatch.
Non-ASCII in Mcp-Name
If a tool name or resource URI contains non-ASCII or control characters, Mcp-Name is carried in the Base64 format. The server and gateway decode it before comparing it with the body value. If you control by tool name at a gateway, check that it supports decoding. It is safest to keep tool names to characters that are safe in headers.
Assuming You Can Resume After a Disconnect
There is no resumption with Last-Event-ID. Do not write an implementation that continues receiving the same stream after a disconnect; reissue with a new request ID. For tools that are not idempotent (for example, payment or sending), include an idempotency key or similar in the design so that reissuing does not execute them twice.
Infrastructure That Assumes Sticky Sessions
Session pinning at the load balancer and routing by session ID are unnecessary in 2026-07-28. It works even if you pin, but it costs you freedom in deployment and scaling. You need the old route only while you accept old clients at the same endpoint.
SSE Is Buffered and Does Not Arrive
If a reverse proxy or CDN buffers the response, progress and subscription notifications arrive late in a batch. Add X-Accel-Buffering: no and turn off buffering in the proxy configuration too. For long-lived subscriptions, send keep-alive comment lines.
Subscriptions Ending Silently
A subscriptions/listen ends because of an intermediary's timeout or a server replacement. The client detects that the stream closed, reopens it, and refetches */list. Whether it closed with a successful response or dropped without one lets you tell a normal end from an unexpected disconnect.
Exposing a Local HTTP Server on 0.0.0.0
If you listen on all interfaces with a development server, other devices on the same network can operate it without authentication. Restrict it to 127.0.0.1, validate Origin, and implement authentication.
4. Code Examples
A Request That Gets a JSON Response (curl)
curl -sS -X POST https://example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: get_weather' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Seattle, WA" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'
A Tool Definition and Request Using x-mcp-header
A tool that copies the value of region into Mcp-Param-Region. A load balancer can route to the cluster for each region without parsing the body.
{
"name": "execute_sql",
"description": "Execute SQL on Google Cloud Spanner",
"inputSchema": {
"type": "object",
"properties": {
"region": {
"type": "string",
"description": "The region to execute the query in",
"x-mcp-header": "Region"
},
"query": {
"type": "string",
"description": "The SQL query to execute"
}
},
"required": ["region", "query"]
}
}
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: execute_sql
Mcp-Param-Region: us-west1
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "execute_sql",
"arguments": { "region": "us-west1", "query": "SELECT * FROM users" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
Encoding Non-ASCII Values
| Original value | Reason | Header value |
|---|---|---|
us-west1 | Plain ASCII | Mcp-Param-Region: us-west1 |
Hello, 世界 | Contains non-ASCII | Mcp-Param-Greeting: =?base64?SGVsbG8sIOS4lueVjA==?= |
padded | Leading and trailing spaces | Mcp-Param-Text: =?base64?IHBhZGRlZCA=?= |
Opening a Subscription (subscriptions/listen)
{
"jsonrpc": "2.0",
"id": 1,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
The acknowledgment that arrives first, and a notification after it. subscriptionId is the same value as the request's id.
{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": { "io.modelcontextprotocol/subscriptionId": 1 },
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"_meta": { "io.modelcontextprotocol/subscriptionId": 1 },
"uri": "file:///project/config.json"
}
}
Per-Method Control at a Gateway (nginx Example)
An example that uses the Mcp-Method header as a variable, sends only tool execution to a separate backend, and turns off SSE buffering. Check variable names and directives against the nginx version and environment you use.
map $http_mcp_method $mcp_backend {
default mcp_general;
"tools/call" mcp_tools;
}
server {
listen 443 ssl;
server_name example.com;
location /mcp {
# The headers are a copy of the body. The server also verifies they match the body
proxy_pass http://$mcp_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # deliver SSE events immediately
proxy_read_timeout 1h; # long enough for subscriptions/listen
}
}
Reissuing After a Disconnect (TypeScript)
A skeleton that reissues a request lost to a disconnect with a new request ID.
let nextId = 1;
async function callWithRetry(
send: (id: number) => Promise<unknown>,
maxAttempts = 3,
): Promise<unknown> {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const id = nextId++; // use a new ID for every reissue
try {
return await send(id);
} catch (err) {
// For example, a stream disconnect. Reissue the same content with a new ID
// (for tools that are not idempotent, the caller prevents double execution)
lastError = err;
}
}
throw lastError;
}
Logging to stderr in a stdio Server (Go)
import (
"log/slog"
"os"
)
// stdout is reserved for MCP messages. Logs go to stderr
var logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
5. Summary
- A transport is a binding that defines how messages are carried, and the meaning of the protocol is the same on every binding
- stdio exchanges newline-delimited messages with a local subprocess.
stdoutis for MCP messages only, and logs go tostderr - Streamable HTTP is POST to a single endpoint, and the response is JSON or a request-scoped SSE stream
MCP-Protocol-Version,Mcp-Method, andMcp-Nameare required, andx-mcp-headercan also turn tool arguments intoMcp-Param-*. The body is the source of truth, and the server verifies the match, rejecting a mismatch with400and-32020- Thanks to the headers, gateways and WAFs can handle routing, rate limiting, and metrics without parsing the body. Header values are still not to be trusted
subscriptions/listenreplaces the GET endpoint andresources/subscribe. There is no resumption viaLast-Event-ID, and after a disconnect you reissue with a new request ID- With no sessions, sticky sessions are unnecessary and horizontal scaling is easy
- HTTP+SSE is deprecated, so migrate to Streamable HTTP. For browsers and local HTTP servers, Origin validation, binding to localhost, and authentication are needed
Related Documents
- Introduction to MCP - What Is the Model Context Protocol
- MCP Protocol Basics - JSON-RPC and Stateless Design
- MCP 2026-07-28 Spec Changes and Migration
- Operating Local MCP Servers Safely
- MCP Authorization - OAuth 2.1 and Metadata Discovery