teachyou.ai academy
← All posts
MCPModel Context ProtocolLLM toolsagent architectureserver-sent events

MCP Transports Compared: stdio, HTTP, and SSE

Pramod Dutta · Jun 26, 2026 · 14 min read

Picking an MCP transport is really a question of where your server lives. If it runs on the same machine as the client, stdio is almost always the right call. If it runs on a different machine, or needs to serve more than one client at a time, you want Streamable HTTP, MCP's current remote transport. The older HTTP+SSE transport still shows up in tutorials and some SDKs, but it has been superseded and you should treat it as a compatibility fallback, not a default. This article walks through how each MCP transport actually works, what breaks when you pick the wrong one, and how to migrate off SSE.

What an MCP transport actually does

The Model Context Protocol separates two concerns: the JSON-RPC message format (requests, responses, notifications) and the transport that carries those messages between a client (an MCP host like Claude Code, an IDE, or a custom agent) and a server (the process exposing tools, resources, and prompts). The message schema stays identical no matter which transport you use. What changes is how bytes move, how connections are established, and what network assumptions you're allowed to make.

That separation matters because it means you can build an MCP server once and expose it over multiple transports without touching your tool implementations. Most SDKs (the TypeScript SDK, the Python SDK) let you swap the transport layer while keeping your server.tool() or @server.tool definitions untouched. The transport is a mechanical detail, but it is the mechanical detail most likely to bite you in production, because it determines your deployment topology, your latency profile, and your security surface.

Three transports show up in real MCP deployments today: stdio, Streamable HTTP, and the older HTTP+SSE pairing. There is also an in-memory transport used mostly for testing, but it doesn't matter for anything you'd ship.

stdio: the local process transport

stdio (standard input/output) is the simplest MCP transport and the one almost every "hello world" MCP server uses. The client spawns the server as a child process. JSON-RPC messages go out over the child's stdin and come back over its stdout, each message delimited by a newline. stderr is left free for logging, which is important: if your server writes anything other than valid JSON-RPC to stdout, you will corrupt the protocol stream and the client will fail to parse it.

Here's a minimal stdio server in Python using the official SDK:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("local-tools")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

if __name__ == "__main__":
    mcp.run(transport="stdio")

And the equivalent client-side config, the kind you'd drop into a host's MCP settings file:

{
  "mcpServers": {
    "local-tools": {
      "command": "python",
      "args": ["server.py"]
    }
  }
}

There is no network layer here at all. No port, no TLS, no auth handshake. The host process owns the child's lifecycle: it starts the server when the session begins and kills it when the session ends. This gives you a few properties that are hard to get any other way.

Security is inherited, not implemented. The server runs with the same OS-level permissions as whatever launched it. There's no token to steal in transit because there's no transit, just pipes between two processes on one machine. This is why stdio is the default for MCP servers that touch the local filesystem, git repos, or developer tooling: the trust boundary is the operating system's process and user model, which you already have to trust anyway.

Latency is effectively zero. Pipe writes and reads on a modern OS are on the order of microseconds, so stdio is the fastest MCP transport by a wide margin. If your tool calls are chatty, e.g. dozens of small tool round-trips per user turn, stdio keeps that overhead invisible.

The tradeoff is that stdio only works for a 1:1, same-machine relationship between client and server. You can't point a stdio server at a teammate's laptop, you can't scale it horizontally behind a load balancer, and you can't have ten different users share one running instance. Each client spawns its own process. For a database connector, a code search tool, or anything backed by local files, that's fine or even desirable. For a server that wraps a shared API with rate limits, or that needs to be reachable by multiple people at once, it's a dead end.

Streamable HTTP: the current remote transport

Streamable HTTP is the transport MCP settled on for anything that isn't local. It replaced the older HTTP+SSE design in the spec and is what you should build toward if you're standing up a server that other people or other machines will connect to over a network.

The mechanics: the server exposes a single HTTP endpoint (commonly /mcp). Clients POST JSON-RPC requests to that endpoint. The server can respond in one of two ways depending on what the request needs:

  • A single JSON response, for simple request/response calls that don't need incremental output.
  • A text/event-stream response (using the same wire format as SSE) when the server needs to stream multiple messages back for one request, such as progress notifications during a long-running tool call, or when it wants to push server-initiated requests to the client mid-call.

The client also opens a GET to the same endpoint to receive a stream for server-to-client messages that aren't tied to a specific request, and the server can include a session ID (via an Mcp-Session-Id header) to correlate everything from a single logical connection across multiple HTTP requests. Because it's just HTTP, standard infrastructure works: reverse proxies, load balancers, auth middleware, TLS termination, all the tooling you already run in front of any web service.

A minimal Streamable HTTP server in TypeScript:

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const app = express();
app.use(express.json());

const server = new McpServer({ name: "remote-tools", version: "1.0.0" });

server.tool("lookupOrder", { orderId: "string" }, async ({ orderId }) => {
  const order = await fetchOrder(orderId);
  return { content: [{ type: "text", text: JSON.stringify(order) }] };
});

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000);

And a client pointing at it:

{
  "mcpServers": {
    "remote-tools": {
      "url": "https://tools.example.com/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN"
      }
    }
  }
}

Streamable HTTP gives you the deployment model people actually want for shared infrastructure: one running server process, many concurrent clients, standard authentication (bearer tokens, OAuth flows defined in the MCP authorization spec), and the ability to scale it the same way you'd scale any HTTP API, behind a load balancer, across multiple replicas, with health checks. Because a single POST can still return a stream, you don't lose the ability to send progress updates during a long tool call; you just don't need a second, separate connection type to get it.

The cost is everything that comes with running a network service: you need to think about authentication, you need TLS in production, you need to handle reconnection and session resumption if a client's connection drops mid-stream, and your infrastructure now has an attack surface that a stdio server never had. None of that is exotic, it's the same operational discipline as any web API, but it is real work that a local stdio server sidesteps entirely.

SSE: the transport MCP moved away from

Before Streamable HTTP, MCP's remote transport was "HTTP+SSE." It used two separate endpoints: clients would open a GET request to an /sse endpoint and hold it open as a long-lived Server-Sent Events stream for server-to-client messages, then POST client-to-server messages to a separate endpoint the server handed back in the initial SSE event.

That split-endpoint design turned out to be a real operational headache. Because the SSE stream had to stay open for the life of the session, every message the server sent, and every long-running tool call, tied up one persistent connection per client. That doesn't scale cleanly behind typical load balancers, doesn't survive network interruptions gracefully, and forces stateful routing (a client's POSTs have to reach the same server instance that's holding its SSE stream open), which complicates horizontal scaling and rolling deploys. It also meant every single interaction, even simple, low-latency ones, paid for a persistent connection whether it needed streaming or not.

Streamable HTTP fixed this by folding streaming into a single endpoint and making it optional per request: a call that doesn't need to stream just gets a plain JSON response, and only calls that genuinely need multiple messages upgrade to an event stream. The spec still allows old-style SSE servers for backward compatibility, and current SDKs generally support both, but new servers should not be built on the plain HTTP+SSE transport. If you inherited a server built on it, or find a tutorial that still uses it, treat it as legacy.

Migrating an SSE server to Streamable HTTP is usually a small, contained change if you're on an official SDK, because the tool definitions, resources, and prompts don't move. You swap the transport class the server binds to and update the single endpoint clients point at:

// Before: two endpoints, persistent SSE connection
// app.get("/sse", ...);
// app.post("/messages", ...);

// After: one endpoint, streaming only when needed
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

If you're maintaining a public server that existing clients depend on, the safe path is to run both transports side by side for a deprecation window, Streamable HTTP as the primary path and the legacy SSE endpoints kept alive read-only, rather than cutting SSE off in one release and breaking every client that hasn't updated its config yet.

Debugging each transport

The failure modes differ enough between transports that it helps to know what to check first.

For stdio, almost every problem traces back to the stdout stream. Run the server binary directly from a terminal and pipe a hand-written JSON-RPC message into it before wiring it up to a host, so you can see raw output without a client masking parse errors. Common culprits: a dependency that prints a startup banner to stdout, a logging library configured with the wrong stream, or an unhandled exception whose traceback leaks onto stdout instead of stderr. If the client reports the server as unresponsive rather than erroring, check whether the process is still alive; a crashed child process with no supervisor looks identical to a hung one from the client's side.

For Streamable HTTP, start by testing the endpoint with a plain HTTP client, curl with a manually constructed JSON-RPC initialize request, before involving an MCP host at all. That isolates whether the problem is in your server's request handling or in the client's config. Watch for the session ID: if your server issues an Mcp-Session-Id on the first response and the client doesn't echo it back on subsequent requests, you'll see requests treated as new sessions instead of continuations, which usually shows up as tools "forgetting" state or the server re-initializing on every call. If you're behind a reverse proxy, check that it isn't buffering the response body, since a proxy that buffers will break streaming responses even though the direct connection works fine.

For legacy SSE, the most common production issue is exactly the one that motivated the redesign: a load balancer without sticky sessions routes a client's POST to a different server instance than the one holding its SSE connection open, and messages silently go nowhere. If you're stuck supporting this transport, either configure session affinity at the load balancer or move to Streamable HTTP, which doesn't have this failure mode because a single request/response cycle doesn't span multiple backend instances.

Performance and cost tradeoffs

The three transports also differ in where they spend resources, which matters once you're running more than a handful of sessions.

stdio's cost is one OS process per client session. That's cheap for a handful of concurrent developers on their own machines, but it doesn't centralize: you can't amortize a warm cache, a database connection pool, or a loaded model across sessions, because each session gets its own process with its own memory. For servers that do meaningful setup work on startup, connecting to a database, loading a large lookup table, that per-session cost adds up.

Streamable HTTP inverts this. One server process (or a small pool of replicas) serves many clients, so you can hold a connection pool, an in-memory cache, or a rate limiter's state centrally and share it across every request. The cost moves from "process spin-up per session" to "concurrent request handling," which is the tradeoff most backend services already make, and the one that scales further with less operational effort.

How to choose

A few questions settle it in practice.

Does the server run on the same machine as the client, one instance per user, with no need for remote access? Use stdio. This covers the overwhelming majority of developer-tooling MCP servers: filesystem access, local git operations, IDE integrations, anything wrapping a CLI that's already installed locally.

Does the server need to be reachable over a network, by multiple clients, possibly from different organizations or machines? Use Streamable HTTP. This covers SaaS-style MCP servers, internal company tool servers shared across a team, and anything you'd want to put behind standard web infrastructure with authentication.

Are you looking at a server or SDK example that only offers /sse and /messages endpoints? That's the legacy transport. It will likely keep working for a while since backward compatibility is part of the spec's design, but don't build new infrastructure on it, and budget time to migrate if you're maintaining it.

One more practical note: these aren't mutually exclusive at the SDK level. The same tool logic can be exposed over stdio for local development and Streamable HTTP for a hosted deployment, because the transport is a thin adapter around the same McpServer instance. Building both isn't wasted effort, it's the same server with two doors.

FAQ

Is SSE deprecated in MCP? The standalone HTTP+SSE transport (separate /sse and /messages endpoints) was replaced by Streamable HTTP as the spec's remote transport. It's kept for backward compatibility so older clients and servers don't break, but it is not the recommended choice for new work. Note that Streamable HTTP still uses SSE's wire format (text/event-stream) internally for the streaming case, so "SSE" as a data format hasn't gone away, only the older two-endpoint transport built around it.

Can I use stdio for a server multiple teammates need to access? Not directly. stdio is a 1:1 relationship between one client process and one server process on the same machine. If several people need access to the same running server, put it behind Streamable HTTP instead, even if it's only reachable inside your company network.

Does switching transports require rewriting my tools? No. In the official SDKs, tools, resources, and prompts are defined against the McpServer object, independent of transport. You attach a transport (stdio, Streamable HTTP) to that server when you start it. Migrating from stdio to HTTP, or from legacy SSE to Streamable HTTP, is a change to your entry-point code, not your tool implementations.

Why does my stdio server hang or crash with garbled output? The most common cause is something in your tool code writing to stdout that isn't a JSON-RPC message, a stray print() statement, a library that logs to stdout by default, or an uncaught exception's traceback. stdio uses stdout exclusively for protocol messages; send all logging to stderr instead.

Do I need authentication for a stdio server? No, and adding one doesn't make sense in the stdio model. Access control happens at the OS process level: whatever can spawn the server process can talk to it. If you need per-user access control, that's a sign you actually want a networked transport with real authentication, not stdio.

How do I handle a dropped connection with Streamable HTTP? The transport supports session resumption via the Mcp-Session-Id header and, on some SDK implementations, event IDs on individual SSE messages so a client can reconnect and ask the server to replay anything sent after the last event it received. Check your specific SDK's resumability support before relying on it in production; not every implementation exposes it yet.

Can a Streamable HTTP server run without ever streaming? Yes. Streaming is optional per response. A server can respond to every request with a single JSON body and never open an event stream, which is a reasonable choice for tools that always complete quickly and don't need to push progress notifications.

MCP Transports Compared: stdio, HTTP, and SSE · TeachYou Academy