Transports & Protocol Messaging

Difficulty

The transport is a separate concern from everything else in MCP — the same JSON-RPC messages, primitives, and lifecycle work identically regardless of which transport carries them.

stdio — for local processes

# Host launches the server as a child process
python weather_server.py

The host writes JSON-RPC requests to the subprocess's stdin (one JSON object per line) and reads responses/notifications from its stdout, also newline-delimited. The server's stderr is reserved for logging/diagnostics — it must never be written to for anything else, since it's not part of the protocol stream.

Streamable HTTP — for remote/shared services

POST https://mcp.example.com/mcp
Content-Type: application/json
Mcp-Session-Id: 9f86d0...

{"jsonrpc": "2.0", "id": 1, "method": "tools/call", ...}

A single HTTP endpoint accepts POSTed JSON-RPC messages; the server can respond with a plain JSON response, or upgrade the response to an SSE stream if it needs to send multiple messages (e.g., progress notifications followed by the final result) for that request.

Choosing between them

A filesystem or local-git server has no reason to run over the network — stdio is simpler and has a smaller attack surface. A hosted "connect your Slack workspace" server has to run remotely and serve many users, so Streamable HTTP with proper authorization is the only sensible option:

stdioStreamable HTTP
Where the server runsLocal subprocess on the user's machineIndependent, possibly remote, host
Networking neededNoYes
Auth neededRarely (process-level trust)Usually (OAuth 2.1)
Good fit forLocal file/git/shell accessSaaS integrations, multi-user services
Session managementImplicit (one process per client)Explicit (Mcp-Session-Id header)

Related Resources

The stdio transport's simplicity — no HTTP, no auth, just pipes — is also exactly where its main foot-gun lives.

The framing rule

{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n
{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}\n

One JSON object per line. No other delimiter, no length header. The client reads stdout line by line and attempts json.loads() (or equivalent) on each line.

What breaks it

# A server that accidentally corrupts its own stdio transport
@mcp.tool()
def fetch_data(url: str) -> str:
    print(f"Fetching {url}...")   # BUG: this goes to stdout, not a log
    data = requests.get(url).text
    return data

That innocuous print() statement writes a line to stdout that isn't valid JSON-RPC. The client's parser either crashes or silently discards it. Either way, the connection is now broken or behaving unpredictably. The bug is often invisible in casual testing if it only triggers occasionally.

The fix: log to stderr, or a proper logger

import sys
def log(msg: str):
    print(msg, file=sys.stderr)   # stderr is safe; it's not part of the protocol

Most SDKs also wire up structured logging — Python's logging module, or the MCP notifications/message logging capability — so ordinary debug output never touches stdout in the first place.

Why this constraint exists at all

stdio is deliberately minimal: no framing overhead, trivial to implement, works with any language that can read/write a subprocess's pipes. The cost of that simplicity is that stdout has to be treated as a strict, single-purpose channel. Some third-party dependencies log to stdout by default, especially older CLI-style libraries. Those have to be explicitly reconfigured or suppressed when running inside an MCP server. Otherwise they intermittently break the transport in a way that's easy to misdiagnose as "the server is flaky" rather than "something wrote to the wrong stream."

Related Resources

MCP's remote transport went through a real redesign once real-world deployments surfaced problems with the original approach.

The old model (HTTP+SSE, 2024-11-05 spec)

Client --GET--> /sse   (opens a long-lived SSE stream; server pushes messages here)
Client --POST-> /messages   (client sends its own messages here, separately)

Two endpoints, and the client had to correlate a persistent SSE connection with a separate POST channel. This made load-balancing across multiple stateless server instances awkward: the SSE connection is pinned to one server instance, but POSTs might land on a different instance behind a load balancer, unless you add sticky sessions or shared state.

The current model (Streamable HTTP)

Client --POST--> /mcp   (single endpoint; body is a JSON-RPC message)

Response is EITHER:
  - a single JSON object (simple request/response), OR
  - an upgrade to an SSE stream, if the server needs to send multiple
    messages related to that request (progress, then final result)

One endpoint, and streaming becomes an opportunistic upgrade rather than a permanently separate channel a client must always maintain.

Why this is a real improvement

  • Simpler to implement and deploy. One route to expose, one set of auth/middleware to apply — closer to conventional REST/JSON API patterns most infrastructure already handles well.
  • Friendlier to standard HTTP infrastructure. Load balancers, API gateways, and serverless platforms handle a single POST endpoint far more naturally than a long-lived, connection-pinned SSE stream that must route to the same backend instance for its whole lifetime.
  • Streaming becomes optional per-request, not a mandatory always-on second connection. Simple servers with no need for server push don't pay for that complexity at all.

What stayed the same

The messages themselves — JSON-RPC requests, responses, notifications — are identical to the old transport. Only the framing and connection model changed. That's exactly the kind of change transport independence is meant to absorb, without touching anything else in the spec.

Related Resources

HTTP is fundamentally stateless. MCP's initialize/capability-negotiation model is inherently stateful. A session has to bridge that gap.

The header

POST /mcp HTTP/1.1
Content-Type: application/json

{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {...}}

--- Response ---
HTTP/1.1 200 OK
Mcp-Session-Id: 3fa2c1e0-9b4d-4a11-8f7a-1d0e9c2b7a55
Content-Type: application/json

{"jsonrpc": "2.0", "id": 1, "result": {...}}

Every subsequent request in this session repeats the header:

POST /mcp HTTP/1.1
Mcp-Session-Id: 3fa2c1e0-9b4d-4a11-8f7a-1d0e9c2b7a55

{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}

Ending a session

DELETE /mcp HTTP/1.1
Mcp-Session-Id: 3fa2c1e0-9b4d-4a11-8f7a-1d0e9c2b7a55

This lets a client explicitly release server-side session resources, instead of leaving them to expire on a timeout.

What the server does with the session ID

It's the key into whatever server-side state needs to persist across requests: which capabilities were negotiated at initialize, active resource subscriptions, any per-session authorization context. Without it, every POST would look like an isolated, brand-new connection with no memory of the handshake that already happened.

Failure handling

If a client sends a session ID the server doesn't recognize — expired, evicted, or never issued — the server typically responds with an HTTP 404. The client should treat that as "this session is gone" and re-run initialize to start fresh, rather than continuing to retry the same stale session ID.

Security implication

The session ID is effectively a bearer credential for that session's context. It should be generated with cryptographically secure randomness and treated like a session cookie. Predictable or guessable session IDs would let an attacker hijack another user's in-progress session.

Related Resources

This is a good example of the spec actively removing a feature, rather than only ever adding one. It's a common trip-up point when reading older MCP tutorials or blog posts that predate the change.

What batching looked like when it existed

// A single message carrying multiple requests
[
  {"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
  {"jsonrpc": "2.0", "id": 2, "method": "resources/list"}
]

// Server replies with a matching array
[
  {"jsonrpc": "2.0", "id": 1, "result": {"tools": [...]}},
  {"jsonrpc": "2.0", "id": 2, "result": {"resources": [...]}}
]

Why it was removed in the 2025-06-18 revision

  • It added meaningful implementation complexity for uncertain benefit. Servers had to handle partial failures within a batch, preserve correct ordering, and reconcile batching with streaming responses over Streamable HTTP.
  • The Streamable HTTP transport already lets a server send multiple messages in response to a single logical request, via an SSE upgrade. That covers most of what batching was used for — getting several pieces of information back efficiently — without needing client-side batch construction.
  • Fewer message shapes to support means simpler, more uniform SDK and server implementations, and fewer edge cases when testing interoperability between independently written clients and servers.

Practical implication

If you're implementing or reviewing an MCP server today, don't build batch-request handling expecting current clients to use it. Don't assume an older tutorial's batching example still applies — check which spec version you're targeting. A server or client built against the 2025-06-18 spec (or later) should assume one JSON-RPC message per HTTP POST body, or per stdio line, full stop.

Related Resources