MCP transports explained: stdio, Streamable HTTP, and the SSE it replaced
Three ways the same JSON-RPC travels, one of them deprecated, and a client that only speaks one of them. Which to build, which to install, and how to tell them apart from a config file.
The protocol is the same in every case: a client sends initialize, then tools/list, then tools/call, each a JSON-RPC message with an id and an answer. The transport decides whether those messages travel down a pipe to a child process, or across HTTP to an address — and that decision, made once in a config file, is most of the difference between "runs on my laptop" and "runs for my team".
stdio: the client runs the server
The client starts your executable as a child process and writes JSON-RPC to its stdin, one message per line; the server answers on stdout the same way. Stderr is yours for logs. There is no network, no port, no authentication, and no way for anybody but that client to reach the server — which is both the whole appeal and the whole limitation.
- Credentials live in the environment. The config entry carries an env block; the server reads the key from it. That is also where the key sits on disk, in every copy of that file.
- One byte on stdout that is not protocol breaks the session. A print statement, a deprecation warning from a dependency, a progress bar — the symptom is always "server disconnected".
- Nothing can be metered, logged or revoked by anybody but the person whose machine it is. A subscription to a stdio server is a promise with no admission point behind it.
- It runs as you. Your files, your environment, your network. A local server with a file-system tool reaches everything you can reach.
{
"mcpServers": {
"issues": {
"command": "npx",
"args": ["-y", "@you/issues-mcp"],
"env": { "ISSUES_TOKEN": "…" }
}
}
}Streamable HTTP: one endpoint, and a stream when it is worth one
Introduced in the 2025-03-26 revision, this is the transport for a server that lives at an address. The client POSTs every message to one URL. The server answers each POST either with a single JSON response or, when it has more to say than one answer — progress, log lines, a long-running call — with a stream of server-sent events on that same response. A separate GET on the same URL opens a standing stream for anything the server wants to send unprompted, and a server that has nothing to send answers that GET with 405.
Two headers carry the state. Mcp-Session-Id is issued by the server on initialize if it wants a session at all — a stateless server simply never issues one, and every POST stands alone. MCP-Protocol-Version, required on every HTTP request since the 2025-06-18 revision, says which revision the client is speaking, so a server can refuse one it does not.
POST /mcp HTTP/1.1
Authorization: Bearer <key>
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2025-06-18
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
HTTP/1.1 200 OK
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"result":{"tools":[…]}}- Stateless is the default worth choosing. No session id, no server memory between calls, any replica can answer any POST. Sessions buy you a standing stream and cost you affinity.
- Notifications get a 202 and no body. A server that answers notifications/initialized with a JSON body is a server that a strict client will complain about.
- Resumability is optional and rarely used. A stream can carry event ids and a client can ask to resume from one; almost nobody does, and a server that skips it loses nothing today.
HTTP+SSE: the one being retired
The original remote transport, from the 2024-11-05 revision, used two endpoints: the client opened a long-lived GET to an SSE endpoint, the server told it which URL to POST messages to, and every answer came back on the standing stream. It works, plenty of servers still speak it, and it was deprecated the moment Streamable HTTP existed — because a design that needs a permanent open connection before it can answer a single request does not survive a load balancer, a proxy timeout or a serverless runtime.
The practical consequence is a compatibility matrix rather than a flag day. A client may still connect to an SSE server, or it may not: Copilot Studio, for one, stopped accepting SSE in 2025, and a server that speaks only SSE simply cannot be added there. If you are choosing, there is nothing to choose; if you are maintaining an SSE server, moving it is one endpoint and an afternoon.
Telling them apart from the config
| You see | It is | What that means |
|---|---|---|
| "command" and "args" | stdio | The client starts a process on this machine; the key is in env |
| "type": "http" or "url" ending in one path | Streamable HTTP | One endpoint; a bearer header carries the key |
| "type": "sse" or a URL ending in /sse | HTTP+SSE | Deprecated; may be refused by newer clients |
| A URL and no type | Whatever the client defaults to | Usually Streamable HTTP now, SSE in older clients — check the client |
What the choice actually decides
Latency is the argument people have, and it is the least important one — a stdio server saves a hop and then waits on the same upstream API a remote one would. The real difference is where four things can live: the credential, the call log, the allowance and the release gate. A stdio server has nowhere to put any of them. A Streamable HTTP server behind a gateway has a natural home for all four, and that is the reason to build remote even when local would run.
- Build stdio when the data is already on the user’s machine — a repository, a local database, a file tree — and there is nothing to meter.
- Build Streamable HTTP when the server holds a credential of its own, when calls cost you money, or when the thing being sold is a subscription that has to stop when it is cancelled.
- Build both when the server is small and the audience is mixed; the tool code is shared and the transport is the last line of the file.
- Never build SSE. Not for a new server, not in 2026.
And when installing rather than building: a config entry with a command runs code on your machine as you, and one with a URL sends your calls to somebody else’s. Neither is wrong. Knowing which one you just pasted is the entire point of being able to read the entry.