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 stdin and writes them to stdout, one message per line; a message must not contain embedded newlines.

  • The server must not write anything to stdout that is not a valid MCP message, and the client must not write anything to stdin that is not one.

  • The server may write UTF-8 text to stderr for 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 request id or 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 mcp 2.2

mcp.run("stdio"); mcp.run("streamable-http") serves the HTTP endpoint on http://127.0.0.1:8000/mcp by default.

TypeScript 2.2

serveStdio(createDocsAssistant) from @modelcontextprotocol/server/stdio; the HTTP server is built with createMcpHandler and the Express adapter.

Java (Spring AI 2.0.1)

The spring-ai-starter-mcp-server starter with spring.ai.mcp.server.stdio=true; spring-ai-starter-mcp-server-webmvc and -webflux add the HTTP transports, selected with spring.ai.mcp.server.protocol=STREAMABLE, STATELESS or SSE.

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 Accept header must list both application/json and text/event-stream.

  • For a notification the server returns 202 Accepted with no body, or an HTTP error status.

  • For a request the server chooses, per request, between Content-Type: application/json (a single response object) and Content-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_required result 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

MCP-Protocol-Version

_meta["io.modelcontextprotocol/protocolVersion"]

Every request; for example MCP-Protocol-Version: 2026-07-28.

Mcp-Method

method

Every request.

Mcp-Name

params.name or params.uri

tools/call, resources/read and prompts/get.

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.

flowchart LR C[MCP client] -->|POST /mcp| LB[Load balancer or gateway] LB -->|round robin| S1[docs-assistant 1] LB --> S2[docs-assistant 2] LB --> S3[docs-assistant 3] S1 & S2 & S3 --> V[(Vector store)]

Three things still need attention when you run more than one instance:

  • Elicitation state. A retry after input_required may reach a different instance than the first attempt, and the requestState it carries must verify there. The instances must share the key that protects it (see the RequestStateSecurity notes on Tools, Resources, Prompts & Elicitation).

  • Long-lived streams. A subscriptions/listen stream 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-Method and Mcp-Name (for example a stricter limit for tools/call on a costly tool) because servers validate those headers against the body. Do not trust them on requests with an older or missing MCP-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.