MCP Transports
Interview answer (say this first). A transport is how MCP bytes move between client and server. There are two current choices: stdio, where the client launches the server as a local subprocess and they exchange newline-delimited JSON over stdin and stdout, and Streamable HTTP, where each message is an HTTP POST to one MCP endpoint and replies come back as JSON or a request-scoped SSE stream. The older HTTP+SSE transport is deprecated. Choose stdio for local, single-user tools with your own credentials; choose Streamable HTTP when a server is shared, remote, multi-tenant, or needs OAuth, elastic scaling, and a central gateway.
Why this exists
The protocol defines what messages mean. It says nothing about how they travel. That gap is deliberate, and it is why MCP works both for a filesystem tool running next to your editor and for a company-wide database service behind a load balancer.
But transport is not a detail you can ignore. It decides:
- Where credentials live. A local subprocess can inherit your OS identity. A remote server needs a token on every request.
- How you scale. Local servers scale by starting more processes on one machine. Remote servers scale by running more instances behind a load balancer.
- What your auth model can be. No network means no OAuth dance. A network hop means TLS, tokens, and origin checks.
- How it fails. A local pipe fails when a process dies. HTTP fails with timeouts, proxies, and rate limits.
- How much latency you add. A local pipe is microseconds. A cross-region HTTP call is tens to hundreds of milliseconds, on every tool call.
Pick the wrong transport and you get a surprising, hard-to-debug system: a remote server with no auth, a local server that hangs forever, or a load balancer that quietly breaks streaming. The transport is the deployment decision, and it deserves its own mental model.
Note:
The one-sentence purpose. The transport decides locality, trust, auth, and scale — the same MCP server behaves very differently depending on whether it is reached over a local pipe or the network.
Start from zero
| Word | Plain meaning |
|---|---|
| Transport | The binding that frames and delivers MCP messages. |
| Binding | The spec’s word for a concrete transport: stdio, Streamable HTTP. |
| stdio | Standard input and output. The transport used for local subprocess servers. |
| Streamable HTTP | The current remote transport: HTTP POST to one endpoint; JSON or SSE replies. |
| SSE | Server-Sent Events. A one-way HTTP stream from server to client. |
| HTTP+SSE | The deprecated remote transport that used a GET stream plus a POST endpoint. |
| MCP endpoint | The single URL that accepts MCP messages, /mcp by default. |
| Framing | How message boundaries are marked. stdio uses newlines. |
| Round trip | One request and its response. |
| Latency | Time added by the transport between send and receive. |
| Back-channel | The ability for a server to send a request back to the client mid-call. |
| Origin header | The browser header naming the page’s origin. Used to block cross-site requests. |
| DNS rebinding | An attack that makes a browser reach a local service via an attacker-controlled hostname. |
| Reverse proxy | A server (nginx, Envoy, a gateway) that forwards HTTP to your app. |
| Sticky session | A load-balancer rule that sends one client to one instance. Needed for stateful sessions. |
| Bearer token | A credential sent in the Authorization header. |
| OAuth | The standard authorization framework MCP uses for remote servers. |
Two pairs that interviewers probe:
- Framing vs semantics. stdio and HTTP carry the same JSON-RPC messages. Only the framing, metadata delivery, and cancellation differ. “Protocol semantics are identical on every transport.”
- Local vs remote is a trust decision, not a performance tweak. It changes who can reach the server and what a breach can touch.
The core idea
Think of two ways to talk to a supplier. stdio is a private phone line in your office: you installed the phone, only you can use it, and it dies when you hang up. Streamable HTTP is a public switchboard: many callers reach it, it must check identity on every call, and it can add lines as demand grows.
flowchart TB
subgraph LOCAL["stdio: local subprocess"]
direction LR
HL["Host"] --> CL["Client"] -->|"stdin: newline JSON"| SL["Server process"]
SL -->|"stdout: newline JSON"| CL
SL -.->|"stderr: logs"| LOG["Log file"]
end
subgraph REMOTE["Streamable HTTP: remote service"]
direction LR
HR["Host"] --> CR["Client"]
CR -->|"POST /mcp + headers"| LB["Load balancer"]
LB --> S1["Server instance 1"]
LB --> S2["Server instance 2"]
S1 -->|"JSON or SSE reply"| CR
S2 -->|"JSON or SSE reply"| CR
end
Both carry identical JSON-RPC messages. The difference is the environment around the pipe.
The three transports side by side
| Property | stdio | Streamable HTTP | HTTP+SSE (deprecated) |
|---|---|---|---|
| Where the server runs | Child process on the same machine | Remote service | Remote service |
| Endpoints | stdin/stdout of the process | One endpoint, POST /mcp | GET /sse + POST /messages/ |
| Who starts it | The client spawns it | Already running | Already running |
| Message direction | Fully bidirectional pipe | Client POSTs; server replies in body or SSE | GET stream plus POSTs |
| Auth | Local OS identity, env vars | Bearer token/OAuth per request | Bearer token |
Session in 2026-07-28 | None (process is the lifetime) | None (stateless core) | Session id, sticky |
| Scaling | More processes on one host | More instances behind a balancer | Sticky sessions required |
| Latency | Local pipe, sub-millisecond | One network round trip per call | One round trip plus a held-open stream |
| Best for | Local tools, per-user credentials, dev | Shared services, multi-tenant, cloud | Legacy compatibility only |
| Adopt in new code? | Yes | Yes | No |
The spec is explicit: HTTP+SSE “has been deprecated since protocol version 2025-03-26” and new implementations should not adopt it. Existing ones should migrate to Streamable HTTP.
How transport changes the deployment shape
| Decision | stdio answer | Streamable HTTP answer |
|---|---|---|
| Where do secrets live? | Process environment and local OS files | Server side; clients send OAuth tokens |
| Who can call it? | Only processes that can spawn it | Anyone who passes auth |
| How do I scale reads? | Start more subprocesses | Add instances behind a balancer |
| How do I revoke access? | Stop the process | Revoke the token, rotate the secret |
| How do I audit? | Host-side logs only | Host logs plus gateway and server logs |
| What is the blast radius? | Your user account and files | Whatever the token and server allow |
Read the last row twice. A local stdio server compromise runs as your user. A remote server compromise is bounded by its token scopes and its network position — which is exactly why those two things must be tight.
How it works
stdio
- The client builds spawn parameters. Command, arguments, optional environment additions, and a working directory.
- The client launches the subprocess. It opens pipes to the child’s stdin and stdout.
- Messages are newline-delimited JSON. Each JSON-RPC message is written as one line, then a
\n. Reading splits on newlines. - stdout is reserved for protocol messages. Server logs go to stderr. Anything else printed to stdout corrupts the stream.
- The environment is inherited safely. The SDK passes only a safe set of variables, then merges your additions on top. Working directory is set explicitly.
- The connection is the process. When the client closes, the subprocess is terminated. There is no separate session to clean up.
- No network, no OAuth. Credentials are whatever the process can read locally.
Streamable HTTP
- The server exposes one endpoint. By default
/mcp, acceptingPOST. - Every request is self-contained. The client POSTs a JSON-RPC message with protocol metadata in
_metaand mirrored headers such as the protocol version and method name. - The response is JSON or SSE. A single result can come back as
application/json, or the server can stream a request-scoped SSE response. Clients must support both. - Long-lived notifications use a listen stream. A
subscriptions/listenrequest returns an SSE stream that stays open and delivers change notifications the client opted into. - Cancel by closing the stream. On HTTP, closing the response stream is the cancellation signal; on stdio the client sends
notifications/cancelled. - No protocol session. In
2026-07-28the server stores no per-connection state, so any instance can serve any request. That is what makes ordinary round-robin load balancing work. - Auth is per request. A bearer token (typically OAuth-issued) travels on each call and is checked each time.
What a real request looks like on the wire
Taken from the 2026-07-28 specification. The metadata is in the body’s _meta, and selected fields are mirrored into headers so gateways can route without parsing JSON.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"},
"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}
Two rules matter here. First, the body is the source of truth; headers are a routing mirror, and servers reject requests where they disagree. Second, the Mcp-Method and Mcp-Name headers let a gateway authorize and rate-limit on the operation without inspecting the payload.
Why statelessness is a transport feature
Before 2026-07-28, a remote client called initialize, the server returned an Mcp-Session-Id, and every later request had to carry it. That pinned the client to one instance, so deployments needed sticky sessions and a shared session store.
Now the request carries everything: a plain round-robin balancer works, and a request can be retried on another instance. If your workflow needs state, you mint an explicit handle from a tool and pass it back as an argument — the model can see it, which is usually better than state hidden in a session cookie.
The syntax you will use
Run a server over stdio. This is the default transport.
from mcp.server.mcpserver import MCPServer
server = MCPServer(name="files", version="1.0.0")
@server.tool(description="Echo the input text.")
def echo(text: str) -> str:
return text
if __name__ == "__main__":
server.run(transport="stdio")
Spawn that server from a client. StdioServerParameters holds the command, args, env, and cwd.
import asyncio
from mcp import Client
from mcp.client.stdio import StdioServerParameters
params = StdioServerParameters(
command="python",
args=["files_server.py"],
env={"MY_TOKEN": "..."}, # merged over a safe default environment
cwd="/srv/mcp",
)
async def main() -> None:
async with Client(params) as client:
print([t.name for t in (await client.list_tools()).tools])
asyncio.run(main())
Run a server over Streamable HTTP. The default bind is 127.0.0.1:8000, and the endpoint is /mcp.
server.run(transport="streamable-http", host="127.0.0.1", port=8000)
Mount the HTTP app inside your own ASGI service. streamable_http_app() returns a Starlette application, so you can add middleware (auth, logging, CORS) or mount it under a prefix.
app = server.streamable_http_app(stateless_http=True) # route is /mcp by default
app.add_middleware(AuthMiddleware) # Starlette middleware
# Or mount it in a larger ASGI app. Mounting at /mcp would produce /mcp/mcp,
# so set the inner path to "/" first:
inner = server.streamable_http_app(stateless_http=True, streamable_http_path="/")
outer = Starlette(routes=[Mount("/mcp", app=inner)])
Connect a client to a remote endpoint. The high-level client accepts a URL directly.
import asyncio
from mcp import Client
async def main() -> None:
async with Client("https://mcp.example.com/mcp") as client:
tools = await client.list_tools()
print([t.name for t in tools.tools])
asyncio.run(main())
Use the transport streams directly for full control. Verified against the SDK: the context manager yields a read/write pair.
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
async def main() -> None:
async with streamable_http_client("https://mcp.example.com/mcp") as (read, write):
async with ClientSession(read, write) as session:
discover = await session.discover() # capability + version exchange
print(discover.supported_versions)
asyncio.run(main())
Add headers or custom auth to the HTTP client. create_mcp_http_client accepts headers, a timeout, and an auth object.
from mcp.client.streamable_http import create_mcp_http_client
http_client = create_mcp_http_client(
headers={"Authorization": "Bearer <token>"},
timeout=30.0,
)
Configure transport security. The server validates Host and Origin to block DNS rebinding. This is verified behaviour: an unexpected host is rejected until you allow it.
from mcp.server.transport_security import TransportSecuritySettings
app = server.streamable_http_app(
stateless_http=True,
transport_security=TransportSecuritySettings(allowed_hosts=["mcp.example.com"]),
)
The older SSE transport still exists for compatibility. Do not start new work on it.
server.run(transport="sse") # GET /sse + POST /messages/; deprecated
Examples: simple to real
Example 1 — the smallest possible local setup. A stdio server is the easiest way to give an agent one local capability.
# echo_server.py
from mcp.server.mcpserver import MCPServer
server = MCPServer(name="echo", version="1.0.0")
@server.tool(description="Echo text.")
def echo(text: str) -> str:
return text
if __name__ == "__main__":
server.run(transport="stdio")
The host spawns this file, lists one tool, and calls it. No network, no ports, no auth service.
Example 2 — the classic stdio failure: a stray print. The server prints a friendly line to stdout, polluting the protocol stream.
@server.tool(description="Echo text.")
def echo(text: str) -> str:
print("got a request!") # WRONG: this line lands in the protocol stream
return text
Because stdout is split on newlines and each line is parsed as JSON, got a request! is a malformed message. The Python SDK logs the parse error and skips that line, so this call still succeeds — but other clients may fail, and relying on that tolerance is fragile. Log to stderr instead (import sys; print(..., file=sys.stderr)), or use the MCP logger.
Example 3 — a remote server, verified end to end over an in-process ASGI transport. This exercises the real Streamable HTTP transport without a network, which makes it a good test pattern.
import asyncio, httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
server = MCPServer(name="math", version="1.0.0")
@server.tool(description="Add two integers.")
def add(a: int, b: int) -> int:
return a + b
async def main():
app = server.streamable_http_app(
stateless_http=True,
transport_security=TransportSecuritySettings(allowed_hosts=["testserver"]),
)
transport = httpx2.ASGITransport(app=app)
http = httpx2.AsyncClient(transport=transport, base_url="http://testserver")
async with app.router.lifespan_context(app):
async with streamable_http_client("http://testserver/mcp", http_client=http) as (r, w):
async with ClientSession(r, w) as session:
print((await session.discover()).supported_versions) # ['2026-07-28']
result = await session.call_tool("add", {"a": 2, "b": 3})
print(result.content[0].text) # "5"
Run this and you exercise the same code path as a deployed HTTP server. Swap the ASGI transport for a real URL and it becomes the production client.
Example 4 — the DNS rebinding guard, verified. With default settings, a request whose Host is not recognised is rejected, protecting a local server from a hostile web page.
WARNING:mcp.server.transport_security:Invalid Host header: testserver
mcp.shared.exceptions.MCPError: Server returned an error response
The fix is configuration, not disabling the check: list the real hostnames in allowed_hosts, and set allowed_origins for browser callers.
Example 5 — latency changes the architecture. Suppose a tool call takes 800 ms of work. Over stdio the added overhead is negligible. Over a cross-region HTTP call, you might add 120 ms per round trip, and an agent that calls ten tools pays it ten times.
stdio : 800 ms work + ~0.2 ms pipe
remote : 800 ms work + ~120 ms network, x10 calls = +1.2 s
The fix is co-location (same region, same VPC), connection reuse, and caching list results so discovery is not repeated on every turn.
Example 6 — scaling story for each transport. This is the comparison interviewers want.
stdio : 1 user x 1 server = 1 process. 1000 users = 1000 processes.
Simple, isolated; costs memory and startup time per user.
HTTP : 1 shared service. Add instances behind a round-robin balancer.
Stateless requests make balancing trivial; tokens carry identity.
With stdio you scale by process fan-out. With HTTP you scale by instance count. They are different operational worlds.
In production
- Never write to stdout in a stdio server. stdout is the protocol. Some clients log and skip a stray
print, but others fail, and relying on that tolerance is fragile. Logs go to stderr. - Override the working directory. A subprocess inherits an unpredictable cwd. Set
cwdso relative paths resolve the same on every machine. - Do not pass secrets through process args. Command lines are visible in process listings. Use the env map or a local secret store.
- Bound the subprocess lifetime. Set call timeouts and make sure shutdown kills the child. A leaked stdio server per user will exhaust memory and file descriptors.
- Bind remote servers to loopback unless you mean it.
127.0.0.1for local development; a real interface only behind TLS, auth, and a firewall. - Keep DNS rebinding protection on in production. Configure
allowed_hostsandallowed_origins; do not disable the check to make a test pass. - Terminate TLS at a proxy or gateway, but forward auth faithfully. The MCP endpoint should see the caller’s identity, not a shared service account.
- Think about proxies and buffering. SSE needs streamed responses. A proxy that buffers breaks streaming and adds latency; disable buffering for the MCP path.
- Set request and body limits. A malicious or buggy caller can send huge arguments. Cap body size, argument count, and result size.
- Migrate off HTTP+SSE. It is deprecated and requires sticky sessions. Streamable HTTP removes that operational burden.
- Retry safely on stateless HTTP. Any instance can serve any request, but retries must be idempotent. Use idempotency keys for writes.
- Co-locate to control latency. Put the agent and the server in the same region; cache
tools/listfor the advertised TTL instead of refetching every turn.
Interview questions
1. When do you use stdio versus Streamable HTTP?
Answer. Use stdio when the server is local, single-user, and can use your own machine’s identity — filesystem tools, Git, local databases, development workflows. Use Streamable HTTP when the server is shared, remote, multi-tenant, or needs OAuth, elastic scaling, and central governance. stdio is a private pipe you start; HTTP is a service you authenticate to.
Follow-up: “Can a server support both?” Yes, and many do. The transport is a deployment choice; the same tool code can be exposed over stdio for local users and HTTP for a hosted service.
Trap. Choosing HTTP by default because it “sounds more production.” For a personal local tool, a subprocess is simpler, faster, and has a smaller attack surface.
2. Why is stdio newline-delimited JSON, and what breaks it?
Answer. The transport writes one JSON-RPC message per line and reads by splitting on newlines. It is the simplest framing over a bidirectional byte stream. Anything else that writes to stdout — a stray print, a library’s banner, a warning — injects a non-JSON line. The Python SDK logs the parse error and skips that line, so the call still succeeds; other clients may fail, and relying on the tolerance is fragile.
Follow-up: “Where should logs go?” To stderr. The specification’s guidance is to log to stderr for stdio transports, and many SDKs route the child’s stderr to the host’s terminal or log sink.
Trap. Blaming the client for a parse error. In stdio integrations, the most common root cause is server output that is not protocol JSON.
3. What is Streamable HTTP, and how does it differ from HTTP+SSE?
Answer. Streamable HTTP uses one endpoint that accepts POST. Each request is self-contained, and the reply is either a single JSON object or a request-scoped SSE stream. HTTP+SSE, the deprecated transport, used two endpoints — a long-lived GET /sse stream plus POST /messages/ for client messages — which forced sticky sessions and held-open connections. Streamable HTTP replaced it in protocol version 2025-03-26.
Follow-up: “Why keep SSE at all in Streamable HTTP?” Because streaming is genuinely useful for progress and for long-running replies. The key difference is that the stream is request-scoped, not the whole connection’s lifeline.
Trap. Saying Streamable HTTP “is just SSE with a new name.” The endpoint model, session model, and scaling properties are different.
4. How does the transport choice change your auth model?
Answer. stdio has no network auth. The process inherits local identity and can read local secrets, so the security boundary is the operating system. Streamable HTTP authenticates every request, typically with an OAuth-issued bearer token, and the server or gateway authorizes each operation. You can revoke a token instantly; revoking a stdio subprocess means stopping it.
Follow-up: “What about multi-tenancy?” stdio is naturally per-user because each user gets their own process. Over HTTP you must carry tenant identity in the token and enforce it per call, or one tenant can reach another’s data.
Trap. Assuming a local server is automatically safe. A stdio server runs with your privileges, so a malicious one can read your files and tokens.
5. How does transport choice affect scaling?
Answer. stdio scales by starting more processes — one per user per server — which is simple and isolated but memory- and startup-heavy. Streamable HTTP scales by adding server instances. Because the 2026-07-28 protocol is stateless, any instance can serve any request, so a round-robin load balancer works with no sticky sessions and no shared session store.
Follow-up: “What changed to make that possible?” Protocol-level sessions and the Mcp-Session-Id header were removed. Metadata moved into each request, and long-lived notifications moved to a subscriptions/listen stream.
Trap. Planning for sticky sessions by default. That was a real requirement before 2026-07-28, but it is no longer needed for the current protocol core.
6. What is DNS rebinding protection, and why does an MCP HTTP server need it?
Answer. A hostile web page can make a browser send requests to a local service using an attacker-controlled hostname that resolves to 127.0.0.1. If the local server trusts any Host header, the page can reach it. MCP servers validate Host and Origin and reject invalid ones with 403 Forbidden. In the Python SDK this is verified: an unrecognised host is rejected until it is added to allowed_hosts.
Follow-up: “How do you fix it without weakening security?” Configure the allowed hosts and origins explicitly, and keep the server bound to loopback. Never disable the check to make a test pass.
Trap. Treating an Invalid Host header error as a bug to suppress. It is the security control doing its job.
7. What failure modes does each transport bring?
Answer. stdio brings process failures: a crashed, hung, or leaked child; stdout corruption; environment and working-directory surprises; and resource cost per user. HTTP brings network failures: timeouts, dropped connections, proxy buffering, rate limits, oversized bodies, TLS and certificate errors, and auth expiry. Both need timeouts and observability; the specific blast radius differs.
Follow-up: “How do you debug a hung call?” Bound every request with a timeout, log the method and arguments, and check the child process or the upstream instance. A hang without a timeout is an outage.
Trap. Treating transport errors as protocol errors. A timeout is not a malformed message, and retrying a non-idempotent write can duplicate it.
8. Can a server be stateful now that the protocol core is stateless?
Answer. Yes, but the state must be explicit. The 2026-07-28 revision removed protocol-level sessions, so the server cannot rely on a connection to remember things. The recommended pattern is to mint a handle from one tool, return it to the model, and have the model pass it back as an argument. State lives in your store and is visible in the conversation, which makes it debuggable and retry-friendly.
Follow-up: “Why is that better than hidden session state?” Because the model can see the handle, thread it through multiple tools, and recover after a retry on a different instance. Hidden transport state either requires sticky sessions or silently breaks.
Trap. Thinking stateless means no state is allowed. It means the protocol does not store it for you.
Remember this
- stdio is a local private pipe — newline-delimited JSON on stdin/stdout, logs on stderr, lifetime tied to the subprocess.
- Streamable HTTP is one endpoint (
POST /mcp) with JSON or request-scoped SSE replies, and it is stateless in2026-07-28. - HTTP+SSE is deprecated. Do not adopt it in new work.
- Transport decides trust, auth, scale, and latency — not just performance.
- Keep DNS rebinding protection on and never print to stdout in a stdio server.