Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

WordPlain meaning
TransportThe binding that frames and delivers MCP messages.
BindingThe spec’s word for a concrete transport: stdio, Streamable HTTP.
stdioStandard input and output. The transport used for local subprocess servers.
Streamable HTTPThe current remote transport: HTTP POST to one endpoint; JSON or SSE replies.
SSEServer-Sent Events. A one-way HTTP stream from server to client.
HTTP+SSEThe deprecated remote transport that used a GET stream plus a POST endpoint.
MCP endpointThe single URL that accepts MCP messages, /mcp by default.
FramingHow message boundaries are marked. stdio uses newlines.
Round tripOne request and its response.
LatencyTime added by the transport between send and receive.
Back-channelThe ability for a server to send a request back to the client mid-call.
Origin headerThe browser header naming the page’s origin. Used to block cross-site requests.
DNS rebindingAn attack that makes a browser reach a local service via an attacker-controlled hostname.
Reverse proxyA server (nginx, Envoy, a gateway) that forwards HTTP to your app.
Sticky sessionA load-balancer rule that sends one client to one instance. Needed for stateful sessions.
Bearer tokenA credential sent in the Authorization header.
OAuthThe 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

PropertystdioStreamable HTTPHTTP+SSE (deprecated)
Where the server runsChild process on the same machineRemote serviceRemote service
Endpointsstdin/stdout of the processOne endpoint, POST /mcpGET /sse + POST /messages/
Who starts itThe client spawns itAlready runningAlready running
Message directionFully bidirectional pipeClient POSTs; server replies in body or SSEGET stream plus POSTs
AuthLocal OS identity, env varsBearer token/OAuth per requestBearer token
Session in 2026-07-28None (process is the lifetime)None (stateless core)Session id, sticky
ScalingMore processes on one hostMore instances behind a balancerSticky sessions required
LatencyLocal pipe, sub-millisecondOne network round trip per callOne round trip plus a held-open stream
Best forLocal tools, per-user credentials, devShared services, multi-tenant, cloudLegacy compatibility only
Adopt in new code?YesYesNo

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

Decisionstdio answerStreamable HTTP answer
Where do secrets live?Process environment and local OS filesServer side; clients send OAuth tokens
Who can call it?Only processes that can spawn itAnyone who passes auth
How do I scale reads?Start more subprocessesAdd instances behind a balancer
How do I revoke access?Stop the processRevoke the token, rotate the secret
How do I audit?Host-side logs onlyHost logs plus gateway and server logs
What is the blast radius?Your user account and filesWhatever 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

  1. The client builds spawn parameters. Command, arguments, optional environment additions, and a working directory.
  2. The client launches the subprocess. It opens pipes to the child’s stdin and stdout.
  3. Messages are newline-delimited JSON. Each JSON-RPC message is written as one line, then a \n. Reading splits on newlines.
  4. stdout is reserved for protocol messages. Server logs go to stderr. Anything else printed to stdout corrupts the stream.
  5. 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.
  6. The connection is the process. When the client closes, the subprocess is terminated. There is no separate session to clean up.
  7. No network, no OAuth. Credentials are whatever the process can read locally.

Streamable HTTP

  1. The server exposes one endpoint. By default /mcp, accepting POST.
  2. Every request is self-contained. The client POSTs a JSON-RPC message with protocol metadata in _meta and mirrored headers such as the protocol version and method name.
  3. 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.
  4. Long-lived notifications use a listen stream. A subscriptions/listen request returns an SSE stream that stays open and delivers change notifications the client opted into.
  5. Cancel by closing the stream. On HTTP, closing the response stream is the cancellation signal; on stdio the client sends notifications/cancelled.
  6. No protocol session. In 2026-07-28 the server stores no per-connection state, so any instance can serve any request. That is what makes ordinary round-robin load balancing work.
  7. 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 cwd so 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.1 for local development; a real interface only behind TLS, auth, and a firewall.
  • Keep DNS rebinding protection on in production. Configure allowed_hosts and allowed_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/list for 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 in 2026-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.