Skip to main content

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.

  1. stdio: the client launches a subprocess and exchanges newline-delimited messages over its standard streams
  2. 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 stdin and writes them to stdout
  • One message is one line, and a message must not contain embedded newlines
  • The server must not write anything to stdout that is not a valid MCP message. It may write logs to stderr as UTF-8 strings
  • The client may capture, forward, or ignore stderr, and must not take stderr output to indicate an error
  • The client must not write anything to stdin that 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.

  1. Responses to client requests (matched by the JSON-RPC id)
  2. Notifications related to an in-flight request (notifications/progress and notifications/message)
  3. Notifications delivered for an active subscriptions/listen. The client matches them to a subscription with io.modelcontextprotocol/subscriptionId in _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/json and text/event-stream in the Accept header. 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 example 400)

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.

  1. The server must validate the Origin header on all connections (as a countermeasure to DNS rebinding). If Origin is present and invalid, it returns 403 Forbidden
  2. A server running locally is recommended to bind only to localhost (127.0.0.1) rather than all interfaces (0.0.0.0)
  3. 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.

HeaderSourceRequired for
MCP-Protocol-Versionio.modelcontextprotocol/protocolVersion in _metaAll POSTs
Mcp-MethodmethodAll requests
Mcp-Nameparams.name or params.uritools/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 number type 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 properties keys. Anything through items, oneOf / anyOf / allOf, if / then / else, or $ref is not allowed
  • An HTTP client excludes a tool definition that violates the constraints from the result of tools/list and 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-Method is tools/call to 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-Region or Mcp-Param-TenantId, designated with x-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-Version indicates 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.

FieldTypeNotification received
toolsListChangedbooleannotifications/tools/list_changed
promptsListChangedbooleannotifications/prompts/list_changed
resourcesListChangedbooleannotifications/resources/list_changed
resourceSubscriptionsstring[]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/acknowledged as the first message, and must not send any notification before it. The notifications in 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/subscriptionId in _meta. The value is the JSON-RPC ID of the subscriptions/listen request. 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/progress and notifications/message are 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/cancelled specifying the ID of the subscriptions/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-ID sent 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/listen is 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 _meta and 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 requestState or in handles of the Tasks extension
  • An SSE stream is tied to one instance only for the duration of one request. A subscriptions/listen is 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.

ItemUp to 2025-11-252026-07-28
SessionsMcp-Session-Id (ended with HTTP DELETE)None
GET streamThe client could open it for server-initiated messagesNone (subscriptions/listen)
Server-initiated requestsCould be sent on SSE streamsNone (MRTR)
ResumptionLast-Event-IDNone

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-Id header: ignore it. Neither issue nor echo session IDs
  • Last-Event-ID header: 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 Origin header and reject with 403 Forbidden if 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, and Mcp-Name triggers a preflight (OPTIONS), so include these in Access-Control-Allow-Headers

2. Comparing stdio and Streamable HTTP​

AspectstdioStreamable HTTP
Main useLocal tools, integrations on a developer's machineRemote servers, organization-wide services
AuthenticationObtained from the execution environment, such as environment variables (does not follow the OAuth spec)Follows OAuth 2.1-based authorization
Distribution and launchThe client launches and manages the processJust register a URL. Operated on the server side
ScalingOne process per clientHorizontally scalable
ObservabilityMainly stderr logsCan 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 valueReasonHeader value
us-west1Plain ASCIIMcp-Param-Region: us-west1
Hello, 世界Contains non-ASCIIMcp-Param-Greeting: =?base64?SGVsbG8sIOS4lueVjA==?=
paddedLeading and trailing spacesMcp-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. stdout is for MCP messages only, and logs go to stderr
  • Streamable HTTP is POST to a single endpoint, and the response is JSON or a request-scoped SSE stream
  • MCP-Protocol-Version, Mcp-Method, and Mcp-Name are required, and x-mcp-header can also turn tool arguments into Mcp-Param-*. The body is the source of truth, and the server verifies the match, rejecting a mismatch with 400 and -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/listen replaces the GET endpoint and resources/subscribe. There is no resumption via Last-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

References​