Most writing about MCP transports jumps straight to the menu — stdio, HTTP with Server-Sent Events, streamable HTTP — and argues about which to pick. That skips the more useful question: what is a transport, and what does the protocol actually demand of one? MCP is a JSON-RPC 2.0 message protocol that is deliberately transport-agnostic. The wire is a replaceable component with a narrow contract, and everything above it — tools, resources, prompts, sampling, the handshake — is written once and works anywhere that contract is honoured. This article stays at that level: the interface a transport implements, what changes when the peer moves from a child process to a datacentre, where framing and liveness quietly break, and how to match a transport to a deployment shape.

What the protocol actually demands of a transport

Strip MCP down and the transport contract is short. A transport must carry discrete messages, not a byte soup: the receiver has to know where one JSON-RPC object ends and the next begins. It must be bidirectional, because the server initiates too — notifications, progress updates, sampling and elicitation requests all travel back toward the host. And it must preserve order within a direction, so a ‘list changed’ notification never overtakes the change it describes.

What a transport is not asked to do matters as much. Correlating a response with its request is JSON-RPC’s job, through the id field, not the wire’s. The transport does not interpret methods, enforce capabilities, or understand tools, and it does not guarantee a response ever arrives — timeouts and cancellation live above it. Any channel that can frame messages and move them both ways can host MCP: pipes today, WebSockets or an in-process channel tomorrow.

MCP transports — stdio, HTTP/SSE, streamable HTTP with capability negotiationone protocol, multiple wiresMCP clienthost processstdio transportchild process pipesHTTP + SSEremote serverStreamable HTTPsingle endpoint bidiJSON-RPC framingrequest / response / notifInit handshakeprotocol versionCapability negotiationtools / resources / promptsAuth + sessionsbearer / OAuth / cookieReconnect + resumeresumable sessionObservabilityframed logs + pingOps — timeouts + rate limits + backpressure per transportspawnconnectstreamauthresumelogtracetunetune
MCP transports and the framing/session machinery around them.
Advertisement

One JSON-RPC layer, many wires

The layering is the point. At the top sits the MCP semantic layer: initialize and capability negotiation, tools/list and tools/call, resource reads, prompt fetches. Beneath it sits JSON-RPC 2.0, defining three message shapes — requests (a method plus an id), responses (the same id plus a result or an error), and notifications (a method with no id, so no reply is expected). Only beneath that does the transport appear.

The dividend is portability. A server can be spawned as a subprocess in development and deployed behind HTTPS in production without one line of tool logic changing, because the tool never sees the wire. Most SDKs make the seam explicit: the server object is constructed first, then connected to a transport instance.

Framing, and why newline-delimited JSON needs care

Framing is where clean designs go wrong in practice. The pipe-based transport uses the simplest framing available: one JSON object per line, newline-terminated. Cheap to implement, trivial to debug in a text editor — and fragile in exactly one way. A serialised message must contain no embedded newlines. Leave a serializer on pretty-print, or splice in a raw line break rather than escaping it as \n, and the receiver sees two malformed half-messages instead of one good one.

The neighbouring hazards are equally mundane. Anything else the process writes to standard output — a debug print, a library banner, a progress bar — is injected straight into the message stream and corrupts it; diagnostics belong on standard error or in MCP’s logging notifications. Streams must be flushed rather than left in a block buffer. Encoding must be UTF-8 on both sides. And a reader must tolerate partial reads, since a large message can arrive across many chunks.

Local or remote — the split that changes everything

Every meaningful difference between transports collapses into one question: is the peer on this machine or across a network? A local transport binds the server’s lifetime to a process the client spawns. Startup is a fork and exec, shutdown is closing a pipe, there is one client by construction, identity is inherited from the operating-system user, credentials come from the environment or a config file, and ‘the network is down’ is not a failure mode that exists.

A remote transport changes all of that. Server lifetime is independent of any client, so state must survive clients coming and going. Many clients share one server, so isolation becomes a design problem rather than a free property — see MCP multi-tenancy. Identity must be proven on every connection. The network can partition, stall, or half-close, making reconnection a first-class concern. And latency jumps from microseconds to milliseconds, so chatty designs that were free locally start to hurt. Nothing in the JSON-RPC layer changes; everything around it does.

Connection lifecycle — setup, liveness, teardown

Setup has two halves that are easy to conflate. The transport half establishes a channel: spawn the process and wire its pipes, or open a TLS connection and authenticate. The protocol half is the initialize exchange that agrees a protocol revision and capabilities. They fail differently — a binary that will not launch is a transport error, a version mismatch is a protocol error with a JSON-RPC shape — which is why clients so often report ‘server failed to start’ when the truth was an expired token.

Once open, the transport owes the layers above an answer nothing else can give: is the peer still there? A closed pipe reports end-of-file and a dead child yields an exit status, so locally ‘gone’ is unambiguous. Networks offer no such courtesy — a connection can sit half-open indefinitely, and an idle stream is indistinguishable from a stalled one, hence ping requests, heartbeats, and separating an idle timeout from a request timeout. Closing is symmetrical: refuse new requests, settle or cancel in-flight ones, then close, escalating a subprocess to a kill only if it hangs — otherwise you leak orphaned processes and callers waiting forever.

Advertisement

Ordering, correlation, and concurrency

MCP is asynchronous and pipelined, not lockstep call-and-return. A client may have several requests outstanding at once, and responses can come back in any order — a fast resources/read may overtake a slow tools/call issued earlier. Correlation is therefore purely by id, which must be unique among the requests a sender still considers open. The transport must never reorder messages within a stream, but it owes no single ordering across the two directions.

The consequences are worth designing for. Notifications carry no id, so they can never be acknowledged or retried — a lost notification is simply lost, which is why they suit change hints rather than critical state transfer. Server-initiated requests travel the same channel in reverse and must not queue behind the client’s own pending work, or a sampling request deadlocks the call that triggered it. And any pending-request map is per-connection: when the channel dies, every entry must be failed, not left to rot.

Reconnection and resumption — what a transport can promise

Networks drop connections and laptops sleep. The honest starting point is that a reconnection creates a genuinely new channel: request identifiers, in-flight state, and stream position do not survive it. The naive recovery is to re-establish and re-run initialize from scratch — correct, but it discards negotiated context and any work in progress.

Doing better needs two things from the transport layer. First, a stable session identifier issued at initialization and presented on reconnect, so the server recognises a returning client rather than minting a fresh session. Second, a replay mechanism: streamed events numbered so a client can resume after the last one it saw — the role the standard SSE event-id and Last-Event-ID convention plays for HTTP transports. Both are only plumbing; whether a resumed session can be honoured depends on how the server holds its state, the subject of MCP sessions. Decide too what happens to a call whose response was in flight when the link dropped: unless it is idempotent, retrying is not obviously safe.

Security at the transport boundary

The transport is where the trust boundary physically sits, and the two shapes carry opposite risks. A local subprocess never touches the network, which sounds safe and mostly is — but it runs with the full privileges of the user who launched it, reading their files and keys with no sandbox in between. The real attack surface is the configuration that names the command: an untrusted server entry, or one whose package was silently updated, is arbitrary code execution with a friendly icon.

Remote transports invert this. The process is contained, but the channel is exposed: use TLS, authenticate every connection rather than assuming the network is private, and treat tokens as scoped to this server — never forwarded upstream on the server’s own initiative. A server listening on a local HTTP port needs specific care: bind to loopback and validate the Origin header, or a web page in the user’s browser can reach it through DNS rebinding. Overload protection at this boundary is covered in MCP rate limiting.

Choosing a transport for a deployment shape

Work backwards from the deployment, not the feature list. If the server must touch the user’s own machine — their filesystem, their git checkout, an installed CLI — a local process transport is not merely convenient, it is the only option with access to the thing. If the server is a shared service with its own credentials, its own scaling story, and users who must not see each other’s data, it is an HTTP service and should be built like one. The awkward middle, a single-user server that happens to live elsewhere, is where a tunnel or a gateway that speaks HTTP outward and pipes inward beats picking a side.

Two rules keep the decision cheap. Prefer the current HTTP-based transport for anything remote and new; the older two-endpoint SSE arrangement is legacy, worth implementing only for compatibility with existing clients. And write your server against the transport interface rather than a wire, so the choice stays reversible.

A transport in MCP is a narrow, replaceable contract: carry framed JSON-RPC messages in both directions, preserve order within a direction, and make it clear when the channel is gone. Everything above it rides any wire that honours that contract, which is why swapping transports should be configuration rather than a rewrite. What genuinely changes is not the message layer but the environment: local gives you process-bound lifetime, one client, and inherited identity, at the price of running with the user’s full privileges; remote gives you independent lifetime and many clients, at the price of authentication, partitions, reconnection, and isolation. Pick from the deployment shape — machine access means local, shared service means HTTP — keep newline framing and standard output clean, and never let wire details leak into tool logic.