MCP Transports: stdio vs Streamable HTTP vs SSE
MCP transports compared: stdio for local servers, Streamable HTTP for remote, and what the SSE deprecation means — with a decision tree and migration steps.
MCP transports are the wire between an MCP client — Claude Code, Cursor, an agent framework — and an MCP server: the layer that carries JSON-RPC messages back and forth. The protocol defines two of them today. stdio runs your server as a local subprocess and speaks over stdin/stdout. Streamable HTTP puts your server behind a URL. A third, HTTP+SSE, was the original remote transport and was deprecated in the 2025-03-26 protocol revision — many clients still support it for backwards compatibility, but new servers shouldn't use it.
The short version: use stdio when the server runs on the same machine as the client (filesystem access, local tooling, personal utilities), use Streamable HTTP when the server is hosted (shared services, OAuth-protected APIs, anything a whole team connects to), and treat SSE as legacy — keep it running if you already ship it, migrate the next time you touch the code. The rest of this post covers what each transport actually does on the wire, why the Streamable HTTP vs SSE debate is settled, and how to migrate an existing server.
The three MCP transports in one table
| stdio | Streamable HTTP | HTTP+SSE (legacy) | |
|---|---|---|---|
| Status | Current | Current (spec rev 2025-03-26) | Deprecated |
| Where the server runs | Subprocess on the client's machine | Any host behind a URL | Any host behind a URL |
| Wire format | Newline-delimited JSON-RPC over stdin/stdout | POST to one endpoint; responses as JSON or an SSE stream | GET /sse stream + POST /messages endpoint |
| Endpoints | None | One (e.g. /mcp) | Two |
| Auth | Inherits local env vars / files | OAuth 2.1, API keys, headers | Same, but bolted on |
| Server → client messages | Any time, over stdout | Optional SSE stream (GET) or streamed POST response | Persistent SSE stream (required) |
| Stateless deployment | N/A | Yes — sessions optional | No — long-lived connection required |
| Load balancers / serverless | N/A | Works cleanly | Needs sticky sessions |
| Clients per running server | Exactly one | Many | Many |
| Best for | Local tools, personal setups | Remote MCP servers, teams, SaaS | Nothing new — migrate |
If you're still building intuition for what the server itself does — tools, resources, prompts — start with what an MCP server actually is and come back. This post is purely about the pipe.
stdio: the local default and its limits
The MCP stdio transport is the one you're using whenever a client config contains a command field. The client spawns your server as a child process, writes JSON-RPC requests to the process's stdin, and reads newline-delimited JSON-RPC responses from its stdout. No ports, no TLS, no auth handshake — the process boundary is the security boundary.
# Claude Code registering a stdio server: everything after -- is the spawn command
claude mcp add postgres -- npx -y @modelcontextprotocol/server-postgres postgresql://localhost:5432/dev
stdio is the right default for local work because of what it doesn't have:
- No network surface. The server can read your filesystem, hit your local database, or shell out — without exposing any of that on a port.
- No auth to build. Credentials come from your environment: env vars in the client config, your
~/.awsfolder, whatever the process inherits. - Trivial to develop. Run the client, and it runs your server. Restart the session, and you get the new build.
The limits are just as structural:
- One client per process. Each MCP client spawns its own copy of the server. There is no shared instance, no shared cache, no shared state.
- Every user runs their own. Shipping a stdio server to a team means every laptop installs it, every laptop updates it, and version drift is your support queue.
- stdout is sacred. Anything your code writes to stdout that isn't a protocol message corrupts the stream. A stray
console.login a dependency is enough to break the session — log to stderr, always. - The client machine is the ceiling. If the server needs GPUs, a warm cache, or a private network path the laptop doesn't have, stdio can't help you.
For the client-side mechanics of registering and scoping stdio servers, the Claude Code MCP setup guide covers claude mcp add, .mcp.json, and scopes in detail.
Streamable HTTP: the remote standard
Streamable HTTP replaced HTTP+SSE as the remote transport in the 2025-03-26 spec revision, and it's a genuinely better design, not just a rename. Everything goes through one endpoint (conventionally /mcp):
- The client POSTs JSON-RPC messages to the endpoint.
- The server answers each POST with either a plain
application/jsonresponse or atext/event-streamresponse — meaning it can stream progress notifications and partial results for that request and then close. - Optionally, the client can open a GET request to the same endpoint to receive an SSE stream for unsolicited server → client messages.
- Sessions are optional and explicit: the server can issue an
Mcp-Session-Idheader on initialization, and the client echoes it on subsequent requests.
That last point is the big deal. Because sessions are opt-in and every message is an independent HTTP request, a Streamable HTTP server can be completely stateless — which means it deploys like any other web service. It works behind load balancers without sticky sessions, runs on serverless platforms that kill idle connections, and scales horizontally by adding instances. The spec also defines resumability: servers can attach event IDs to SSE messages, and a client that lost its connection can reconnect with Last-Event-ID and replay what it missed.
Streamable HTTP is also where real authentication lives. The MCP authorization spec (OAuth 2.1) is defined for HTTP transports, so a remote MCP server can require scoped tokens per user rather than trusting whatever environment spawned it. That's the model localskills.sh uses for its own MCP server: agents search and install skills from the registry over MCP, gated by OAuth with scoped access — something a stdio binary on each laptop couldn't enforce centrally.
If you're writing one from scratch, our guide to building an MCP server in TypeScript walks through the SDK end to end; the transport is a two-line choice once the tools are defined.
SSE deprecation: what it actually means for you
If you've hit "MCP SSE deprecated" warnings in SDK changelogs and wondered whether your server is about to stop working: no, but the clock is ticking. Here's the precise situation.
The original remote transport (protocol version 2024-11-05) used two endpoints. The client opened a long-lived GET /sse connection; the server's first SSE event told the client which URL to POST messages to (typically /messages?sessionId=...); and every server → client message for the life of the session flowed over that one persistent stream.
That design had three problems that showed up the moment people deployed real remote MCP servers:
- The connection was mandatory state. If the SSE stream dropped — proxy timeout, laptop lid, serverless platform reaping idle connections — the session was gone, with no resume mechanism.
- Horizontal scaling was painful. The POST endpoint had to reach the exact instance holding the SSE stream, so load balancers needed sticky sessions and serverless deployments were effectively ruled out.
- Two endpoints, one session. Auth, routing, and observability all had to be wired twice and correlated manually.
The 2025-03-26 revision deprecated HTTP+SSE in favor of Streamable HTTP, which fixes all three. So the Streamable HTTP vs SSE comparison isn't a trade-off between two live options — it's a current standard versus its predecessor.
What deprecation means in practice:
- Existing SSE servers still work with clients that kept backwards compatibility, and the spec documents how: a client can attempt a Streamable HTTP
POSTfirst and fall back to the oldGET /sseflow if it gets a 4xx. - New clients aren't obligated to keep the fallback forever. Each release cycle, SSE support gets a little less universal.
- New servers should not ship SSE. Every SDK's current examples and hosting guides assume Streamable HTTP.
- Migration is small. In the official SDKs it's mostly swapping a transport class — covered below.
Decision tree: picking between MCP transports for your server
Work through these in order; the first "yes" decides it.
- Does the server need the user's machine — their filesystem, local git repos, local databases, shell? → stdio. A network hop can't reach a laptop's disk. This covers most personal-productivity servers.
- Will multiple people or agents share one running instance — shared cache, shared connection pool, central config? → Streamable HTTP. stdio physically cannot do this; every client gets its own process.
- Does the server hold credentials users shouldn't have? A service account, a database the team queries through the server but can't log into directly? → Streamable HTTP with OAuth. Central auth is the point of a remote MCP server.
- Is it a thin wrapper over an API where each user already has their own key? → Either works. stdio with the key in an env var is simpler to build; Streamable HTTP is simpler to distribute, because updating the server updates it for everyone at once.
- Still unsure? → Start with stdio. It's less code, easier to debug, and the MCP SDKs make the transport swappable — moving to Streamable HTTP later doesn't touch your tool definitions.
One distribution note: if the thing you're shipping to teammates is instructions and conventions rather than live tool execution, you may not want an MCP server at all — a skill on localskills.sh installs as plain files into each tool's native format with no process to run. Transports only matter when there's a live server on the other end.
Migrating an SSE server to Streamable HTTP
Here's the migration for a TypeScript server using the official SDK. The tool definitions don't change at all — only the transport wiring does.
1. What the legacy SSE wiring looks like
// BEFORE: two endpoints, mandatory per-session state
import express from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
const app = express();
const transports: Record<string, SSEServerTransport> = {};
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
transports[transport.sessionId] = transport;
res.on("close", () => delete transports[transport.sessionId]);
await server.connect(transport);
});
app.post("/messages", async (req, res) => {
const transport = transports[req.query.sessionId as string];
if (!transport) return res.status(400).send("Unknown session");
await transport.handlePostMessage(req, res);
});
2. Replace it with one Streamable HTTP endpoint
For most servers, stateless mode is the right call — no session registry, no sticky sessions, nothing to clean up:
// AFTER: one endpoint, stateless
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no Mcp-Session-Id issued
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
If your server genuinely holds per-conversation state, pass a sessionIdGenerator (for example randomUUID) and keep a transport map keyed by session ID — the SDK handles issuing and validating the Mcp-Session-Id header.
3. Keep the old endpoints during the transition (optional)
Nothing stops you from mounting both transports on the same server while clients catch up: /mcp for Streamable HTTP, /sse + /messages for stragglers. Both wrap the same McpServer instance. Drop the legacy routes once your client logs show the old endpoints going quiet.
4. Update client configs
Clients that pointed at the SSE URL need the new endpoint and type:
{
"mcpServers": {
"my-server": {
"type": "http",
"url": "https://mcp.example.com/mcp"
}
}
}
In Claude Code that's claude mcp add --transport http my-server https://mcp.example.com/mcp.
5. Verify before you announce it
Point MCP Inspector at the new endpoint, run initialize, list tools, and call one. Our MCP Inspector debugging guide covers the workflow — five minutes there beats a teammate discovering the migration for you.
The pattern to internalize: transports are plumbing, deliberately decoupled from your server's logic. Pick stdio for anything local, Streamable HTTP for anything hosted, and leave SSE where the spec left it.
Shipping skills alongside your MCP servers? localskills.sh is a registry for agent skills and rules — publish once, install to Claude Code, Cursor, Windsurf, and more, with versioning and team access built in.
npm install -g @localskills/cli
localskills install <org>/<skill> --target cursor claude windsurf