Transports
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the 2026-07-28 revision of
the specification, the Python mcp SDK 2.2.0 (uvicorn serving the Streamable HTTP endpoint), @modelcontextprotocol/server
2.2.0 and Spring AI 2.0.1 (which resolves the MCP Java SDK 2.0.0; the standalone SDK is at 2.0.1), as checked on 2026-09-29 (see the
Model Context Protocol baseline). The curl session was captured from the Python
docs-assistant server on Python 3.12. The Java SDK and Spring AI speak protocol 2025-11-25 only, so their
Streamable HTTP is the earlier, session-based shape; the differences are called out below.
The messages themselves are the same everywhere — JSON-RPC 2.0, described on MCP Architecture. A transport is only the channel that carries them, and MCP defines two: stdio, for servers the host launches as a local subprocess, and Streamable HTTP, for servers that run somewhere else. A third, HTTP+SSE, still exists in the specification and is deprecated. The 2026-07-28 revision cut the state out of Streamable HTTP, which is what makes remote MCP servers deployable like any other web service.
stdio
In the stdio transport the client starts the server as a child process and the two talk over the child’s standard streams. The rules are short and strict:
-
The server reads JSON-RPC messages from
stdinand writes them tostdout, one message per line; a message must not contain embedded newlines. -
The server must not write anything to
stdoutthat is not a valid MCP message, and the client must not write anything tostdinthat is not one. -
The server may write UTF-8 text to
stderrfor logging of any kind; clients may capture, forward or ignore it, and should not treat it as an error signal. -
Everything shares one channel, so there are no per-request streams. Notifications that belong to a request and notifications from active subscriptions travel on the same
stdout, correlated by requestidor subscription id.
The consequence that catches most first-time authors is the second rule: a stray print() or console.log() in a
stdio server corrupts the protocol stream. Log to stderr (in Python, print(…, file=sys.stderr) or the logging
module, which writes to stderr by default; in Node.js, console.error). Because stdio has no HTTP status code to
signal "this server does not speak the modern protocol", a client that supports both eras sends server/discover
first and falls back to initialize if that fails.
The same server answered a server/discover request on stdin like this (the command was
python server.py stdio, the reply is one line on stdout, wrapped here for reading):
{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},
"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"resultType":"complete",
"supportedVersions":["2026-07-28"],"ttlMs":0,
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs-assistant","version":"1.0.0"}}}}
Stdio servers do not use the HTTP authorization flow. The specification says implementations on stdio should not follow it and should take credentials from the environment instead, which is how the hosts pass API keys to local servers (see Connecting Hosts). The three stacks select stdio like this:
| Stack | Selecting stdio |
|---|---|
Python |
|
TypeScript 2.2 |
|
Java (Spring AI 2.0.1) |
The |
The full server code is on Python MCP Servers, Spring Boot MCP Servers and Node.js MCP Servers. See the stdio transport specification.
Streamable HTTP
Streamable HTTP is the transport for remote servers. The server exposes one HTTP endpoint — for example
https://example.com/mcp — and the whole protocol runs over POST requests to it:
-
The client sends every JSON-RPC request or notification as its own HTTP
POST. The body is exactly one message, and the client must not send JSON-RPC responses. -
The client’s
Acceptheader must list bothapplication/jsonandtext/event-stream. -
For a notification the server returns
202 Acceptedwith no body, or an HTTP error status. -
For a request the server chooses, per request, between
Content-Type: application/json(a single response object) andContent-Type: text/event-stream(a Server-Sent Events stream scoped to that request, carrying any progress notifications followed by the final response). Clients must handle both. -
The server must not send its own JSON-RPC requests on that stream. Anything the server needs from the client arrives as an
input_requiredresult and the client retries (see Tools, Resources, Prompts & Elicitation). -
Closing the response stream is the cancellation signal for that request; the server should stop work on it and send nothing more.
Compared with the protocol versions 2025-03-26 to 2025-11-25, the revision removed the standalone GET stream, protocol
sessions and the Mcp-Session-Id header, server-sent requests on streams, and Last-Event-ID resumability. A server
that supports only the current revision answers GET or DELETE on the endpoint with 405 Method Not Allowed, ignores
a Mcp-Session-Id or Last-Event-ID header and never mints session ids.
Request metadata headers
An HTTP gateway should be able to route, rate-limit and log MCP traffic without parsing JSON bodies, so the transport
mirrors selected body fields into headers. Every POST must carry:
| Header | Source in the body | Required for |
|---|---|---|
|
|
Every request; for example |
|
|
Every request. |
|
|
|
The headers are not optional decoration: a server that processes the body must check that each one matches the body and
must reject a mismatch with 400 Bad Request and JSON-RPC error -32020 (HeaderMismatch). That closes a real attack: a
gateway that routes or authorises on the header while the server executes what the body says can be walked around by a
client that lies in one of them. Header names are case-insensitive, values are case-sensitive. A value that cannot be
carried as plain ASCII (non-ASCII text, control characters, leading or trailing whitespace) is sent as
=?base64?<Base64 of the UTF-8 bytes>?=, and servers must decode it before comparing; a plain value that happens to
look like that sentinel is also Base64-encoded to avoid ambiguity. An unsupported version has its own error:
400 with UnsupportedProtocolVersion (-32022, listing the supported versions), and an unknown method is 404 with a
JSON-RPC -32601 body so a client can tell a modern server from a legacy HTTP+SSE server that returned a bare 404.
Intermediaries that enforce policy on these headers should also check that MCP-Protocol-Version names a version that
requires header validation; if it is older or absent they should reject the request rather than trust unvalidated
header values.
Custom headers with x-mcp-header
A server can ask for parameters of a specific tool to be mirrored into headers as well, so a gateway can, say, route a
query by region. It marks the property in the tool’s inputSchema with x-mcp-header; the value is the name part of a
Mcp-Param-<name> header:
{
"name": "execute_sql",
"inputSchema": {
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" },
"query": { "type": "string" }
},
"required": ["region", "query"]
}
}
A call with "region": "us-west1" then carries Mcp-Param-Region: us-west1 next to Mcp-Method: tools/call and
Mcp-Name: execute_sql. Clients using Streamable HTTP must support this; the annotation is only valid on string,
integer and boolean properties (not number), each name must be unique case-insensitively, and the property must be
statically reachable through properties keys only. A client must drop a tool whose annotations break these rules from its
tools/list result. If a client omits a required Mcp-Param-* header, the server answers -32020, and the client is
expected to refresh tools/list and retry. Because headers are visible to every intermediary, never mark a secret, a
token or personal data with x-mcp-header.
Long-lived notifications with subscriptions/listen
With the GET stream gone, a client that wants change notifications opens a stream deliberately by sending a
subscriptions/listen request whose notifications filter lists what it wants:
toolsListChanged, promptsListChanged, resourcesListChanged and resourceSubscriptions (a list of resource URIs).
The server’s response is the stream: it stays open, and the server sends only the notification types the client asked
for. The first message is always notifications/subscriptions/acknowledged, carrying the subscription id in _meta
and the subset of the filter the server agreed to honour. The captured response from the Python server for a client that
asked for toolsListChanged:
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache, no-transform
connection: keep-alive
x-accel-buffering: no
transfer-encoding: chunked
event: message
data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":9},"notifications":{"toolsListChanged":true}}}
Two operational details come from the specification. The server should send X-Accel-Buffering: no on event streams so
that reverse proxies such as nginx do not buffer events, which the response above does. And for long-lived streams,
servers are encouraged to emit an SSE comment line (a line beginning with a colon) periodically as a keep-alive so that
intermediaries and client idle timeouts do not close a quiet connection. Request-scoped notifications such as progress
never appear on the listen stream. Notifications on a listen stream carry io.modelcontextprotocol/subscriptionId
in _meta so the client can tell subscriptions apart; on stdio, where several subscriptions share one channel, this id
is the only way to do so.
Security for the endpoint
Servers must validate the Origin header on all incoming connections to prevent DNS rebinding, and answer 403 when it
is present and invalid. A server that runs locally should bind to 127.0.0.1 rather than all interfaces, and every
server should authenticate connections. The SDKs default to this: the Python server allows only localhost Host values
unless configured (a request with another Host gets 421), and the TypeScript createMcpExpressApp() validates Host
and Origin when bound to localhost. Authorization for remote servers is covered on
Authorization.
Why HTTP+SSE is deprecated
The original remote transport, from protocol version 2024-11-05, used two endpoints: a long-lived GET that opened an
SSE stream carrying every server-to-client message, and a separate POST endpoint whose URL the server announced on that
stream for client-to-server messages. Every client therefore needed a permanent connection to one server process before
it could send anything, the two channels had to be correlated by session, and a dropped stream lost the session.
Streamable HTTP, introduced in 2025-03-26, replaced it with a single endpoint and optional streaming, and the
specification has classed HTTP+SSE as Deprecated since then; new implementations should not adopt it and existing ones
should migrate. It is eligible for removal in a future revision. The Spring AI starters still offer it as
spring.ai.mcp.server.protocol=SSE for old clients, and mark it deprecated since Spring AI 2.0.0 (see Spring Boot MCP Servers);
choose STREAMABLE or STATELESS for anything new.
Load balancing without sticky sessions
Before 2026-07-28 a remote MCP deployment had to solve session affinity: the session id, the negotiated capabilities and any pending server request lived in one process, so the balancer had to pin every request of a session to it or the servers had to share a session store. The revision removes the cause. Every request declares its version and capabilities, needs no earlier request, and its response stream belongs to it alone, so any healthy instance can serve any request.
Three things still need attention when you run more than one instance:
-
Elicitation state. A retry after
input_requiredmay reach a different instance than the first attempt, and therequestStateit carries must verify there. The instances must share the key that protects it (see theRequestStateSecuritynotes on Tools, Resources, Prompts & Elicitation). -
Long-lived streams. A
subscriptions/listenstream is pinned to whichever instance accepted it for as long as it is open, so idle timeouts and connection draining on the balancer must allow for it; a client whose stream drops simply opens a new one, because nothing about it is resumable. -
Header-based routing. Gateways can route or rate-limit on
Mcp-MethodandMcp-Name(for example a stricter limit fortools/callon a costly tool) because servers validate those headers against the body. Do not trust them on requests with an older or missingMCP-Protocol-Version.
Java servers built on the current SDK differ here: the Spring AI STREAMABLE mode is the 2025-11-25 shape and keeps a
session id, so it still needs affinity or a single instance; the STATELESS mode holds no session and scales behind a
balancer freely, at the price of features that need a back-channel. Scaling, gateways and containers are the subject of
Deploying Remote Servers.
A curl walkthrough
The most useful test tool for a Streamable HTTP server is the one you already have, because every message is a plain
POST. The session below runs against the Python docs-assistant (started with python server.py streamable-http,
listening on http://127.0.0.1:8000/mcp) and shows the real responses, with the date header removed and one long
requestState shortened. Every request carries the same _meta, which the shell keeps in a variable:
URL=http://127.0.0.1:8000/mcp
META='{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}},
"io.modelcontextprotocol/clientInfo":{"name":"curl-demo","version":"0.1.0"}}'
Step 1 — discover
curl -si -X POST "$URL" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":'"$META"'}}'
HTTP/1.1 200 OK
server: uvicorn
content-length: 345
content-type: application/json
{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs-assistant","version":"1.0.0"}}}}
The server answered with application/json rather than a stream, because there was nothing to stream. There is no
Mcp-Session-Id in the reply and none is needed.
Step 2 — list the tools
The request differs only in the method (in the body and in Mcp-Method):
curl -si -X POST "$URL" \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":'"$META"'}}'
HTTP/1.1 200 OK
server: uvicorn
content-length: 1272
content-type: application/json
{"jsonrpc":"2.0","id":2,"result":{"cacheScope":"private","resultType":"complete","tools":[{"annotations":{"openWorldHint":false,"readOnlyHint":true},"description":"Search the documentation of one component and return the best matching pages.","inputSchema":{"type":"object","properties":{"query":{"title":"Query","type":"string"},"component":{"anyOf":[{"type":"string"},{"type":"null"}],"default":null,"title":"Component"},"top_k":{"default":3,"title":"Top K","type":"integer"}},"required":["query"],"title":"search_docsArguments"},"name":"search_docs","outputSchema":{ ... }}],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs-assistant","version":"1.0.0"}}}}
The outputSchema is elided here ({ … }); it is shown in full on Tools, Resources,
Prompts & Elicitation. The optional component argument is what the elicitation in step 4 supplies.
Step 3 — call search_docs
This call names the tool, so it carries the third standard header, Mcp-Name:
curl -si -X POST "$URL" \
-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: search_docs' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_docs",
"arguments":{"query":"matrix decomposition","component":"algebra","top_k":2},"_meta":'"$META"'}}'
HTTP/1.1 200 OK
server: uvicorn
content-length: 1140
content-type: application/json
{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"{\n \"query\": \"matrix decomposition\", ... }","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"query":"matrix decomposition","component":"algebra","hits":[{"component":"algebra","page":"matrices","title":"Matrices","score":2,"snippet":"Matrix decompositions such as LU, QR and SVD solve linear systems."},{"component":"algebra","page":"wrappers","title":"Wrappers","score":1,"snippet":"Wrapper classes hide the decomposition details behind a simple solver."}]},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs-assistant","version":"1.0.0"}}}}
Step 4 — the input_required round trip
Omit component and the server needs an answer before it can search. Only the arguments object changes:
curl -si -X POST "$URL" \
-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: search_docs' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_docs",
"arguments":{"query":"matrix"},"_meta":'"$META"'}}'
HTTP/1.1 200 OK
server: uvicorn
content-length: 903
content-type: application/json
{"jsonrpc":"2.0","id":4,"result":{"inputRequests":{"__main__:choose_component":{"method":"elicitation/create","params":{"message":"Which component should I search?","mode":"form","requestedSchema":{"properties":{"component":{"description":"Which component to search","enum":["algebra","units","statistics"],"title":"Component","type":"string"}},"required":["component"],"type":"object"}}}},"requestState":"v1.XpCP-mS7hdlgo4cFd19Rv6uoBPNbU75Qby...","resultType":"input_required","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs-assistant","version":"1.0.0"}}}}
The 200 status is deliberate: input_required is a normal, successful result, not an error. The client (here, you)
asks the user, then repeats the call with a new id, the answer under the server’s key, and the untouched
requestState:
curl -si -X POST "$URL" \
-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: search_docs' \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"search_docs",
"arguments":{"query":"matrix"},
"inputResponses":{"__main__:choose_component":{"action":"accept","content":{"component":"algebra"}}},
"requestState":"<the requestState value from the previous response, unchanged>","_meta":'"$META"'}}'
HTTP/1.1 200 OK
server: uvicorn
content-length: 729
content-type: application/json
{"jsonrpc":"2.0","id":5,"result":{"content":[{"text":"{\n \"query\": \"matrix\", ... }","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"query":"matrix","component":"algebra","hits":[{"component":"algebra","page":"matrices","title":"Matrices","score":1,"snippet":"Matrix decompositions such as LU, QR and SVD solve linear systems."}]},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs-assistant","version":"1.0.0"}}}}
Steps 1 to 4 are the exact sequence the sub-section’s contract script runs (see
Testing & Debugging); the script extracts the requestState and the key with
jq instead of pasting them.
Step 5 — what a wrong header looks like
Send a Mcp-Name that disagrees with params.name:
curl -si -X POST "$URL" \
-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: wrong' \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"search_docs",
"arguments":{"query":"matrix","component":"algebra"},"_meta":'"$META"'}}'
HTTP/1.1 400 Bad Request
server: uvicorn
content-length: 127
content-type: application/json
{"jsonrpc":"2.0","id":6,"error":{"code":-32020,"message":"mcp-name header does not match the request body's 'name' parameter"}}
The server refused before running the tool: 400 plus -32020, exactly as the transport specification requires.
Declaring MCP-Protocol-Version: 2099-01-01 (and the same value in _meta) instead is answered with 400 and
{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28"],"requested":"2099-01-01"}}.
The -32020 case is also why hand-written curl calls to an MCP server fail in confusing ways: forgetting Mcp-Method
or Mcp-Name is a protocol violation, not a client nicety. The MCP Inspector, and every SDK client, set them for you.
Use curl to understand the wire and to script a smoke test; use the Inspector CLI for everyday checks.