5. Remote servers and auth¶
Intermediate → Advanced · 14 min read
A stdio server runs on the user's machine, as the user. That's perfect for local files and dev tools, and useless for a company API that a thousand users (or ChatGPT, or the Claude API) need to reach. Remote servers run as a web service over Streamable HTTP, and as soon as they're on the network they need authorization.
| stdio | Streamable HTTP | |
|---|---|---|
| Runs where | On the user's machine, started by the host | Anywhere: your cloud, behind a load balancer |
| Who can reach it | Only the host that started it | Anyone with the URL (so: auth) |
| Auth | Environment variables / the user's own credentials | OAuth 2.1 bearer tokens |
| Scaling | One process per host | Many replicas, any can answer any request |
| Good for | Files, git, local databases, CLIs | SaaS integrations, company APIs, shared tools |
The old HTTP+SSE transport (two endpoints, from 2024) is deprecated. Use Streamable HTTP for anything new.
5.1 Running a server over HTTP¶
The same MCPServer from page 3 serves HTTP with one argument:
# no-run — pip install "mcp>=2"
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000) # serves POST /mcp
A client connects with just the URL:
# no-run
async with Client("http://localhost:8000/mcp") as client:
print(client.protocol_version)
result = await client.call_tool("get_order_status", {"order_id": "A100"})
print(result.structured_content)
5.2 What goes over the wire¶
Streamable HTTP is just one endpoint that accepts POSTs of JSON-RPC messages. The response is either plain JSON
or, if the server wants to stream progress first, a Server-Sent Events stream. You can talk to it with curl. These
are real requests against the server from page 3:
curl -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: get_order_status" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"get_order_status","arguments":{"order_id":"A101"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\n \"order_id\": \"A101\",\n \"status\": \"processing\",\n \"eta\": \"Monday\"\n}","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"order_id":"A101","status":"processing","eta":"Monday"},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"orders","version":""}}}}
And server/discover, which a client can call first to see what the server supports:
{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"instructions":"Order lookups and refunds for ShopKart.","resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"orders","version":""}}}}
Why the Mcp-Method and Mcp-Name headers?¶
They copy the method and tool name out of the JSON body into HTTP headers, so gateways, WAFs and load balancers
can route, rate-limit and log by tool without parsing JSON: "allow tools/call for get_order_status to everyone,
refund_order only to the support team". The server checks that the headers match the body, so they can't be used
to sneak past those rules. Here's what happens if they disagree:
{"jsonrpc":"2.0","id":4,"error":{"code":-32020,"message":"mcp-name header does not match the request body's 'name' parameter"}}
5.3 Stateless means easy to scale¶
In the 2026-07-28 spec there's no session: no initialize, no Mcp-Session-Id, no sticky routing. Every request
carries its own protocol version and capabilities. So a remote MCP server deploys like any REST API:
flowchart LR
H1[Claude] --> LB[Load balancer / API gateway<br/>auth, rate limits by Mcp-Name]
H2[Cursor] --> LB
H3[Your agent] --> LB
LB --> R1[MCP replica 1]
LB --> R2[MCP replica 2]
LB --> R3[MCP replica 3]
R1 & R2 & R3 --> API[(Orders API / DB)]
What that means for your code:
- No per-connection state in memory. If a tool needs state across calls (a shopping cart, a long export), store
it in Redis or a database and return a handle (
cart_id,export_id) that the model passes to the next tool. - Multi round-trip elicitation keeps working: the client's retry carries the answers, so any replica can finish the call (page 2).
- A dropped stream loses the request. Stream resumption was removed; the client re-sends with a new ID. Make write tools idempotent (accept an idempotency key) so a retry can't refund twice.
Older clients still use the handshake and sessions. The Python SDK serves both; if you must support old clients
across several replicas, stateless_http=True stops the server tying them to one instance.
5.4 Mounting MCP in your FastAPI app¶
If you already have a FastAPI service, mount the MCP server next to your REST routes instead of running a second
process. streamable_http_app() returns a Starlette app:
# no-run — pip install "mcp>=2" fastapi uvicorn
import contextlib
from fastapi import FastAPI
from orders_server import mcp # the MCPServer from page 3
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
async with mcp.session_manager.run(): # starts the MCP transport's background tasks
yield
app = FastAPI(lifespan=lifespan)
app.mount("/orders", mcp.streamable_http_app()) # MCP endpoint at /orders/mcp
@app.get("/health")
def health():
return {"ok": True}
# uvicorn main:app --host 0.0.0.0 --port 8000
Your existing middleware (CORS, logging, auth), health checks and deployment all apply. See FastAPI for GenAI and Docker and deployment.
5.5 Authorization: OAuth 2.1¶
A remote MCP server is an OAuth 2.1 resource server. It doesn't handle logins itself; it accepts bearer tokens issued by an authorization server (Auth0, Okta, Entra ID, Keycloak, your own) and checks them on every request.
sequenceDiagram
participant C as MCP client (host)
participant M as MCP server
participant A as Authorization server
C->>M: POST /mcp (no token)
M-->>C: 401 + WWW-Authenticate: resource_metadata=".../.well-known/oauth-protected-resource"
C->>M: GET /.well-known/oauth-protected-resource
M-->>C: {"authorization_servers": ["https://auth.example.com"], "scopes_supported": [...]}
C->>A: OAuth 2.1 authorization code + PKCE (user logs in and consents in a browser)
A-->>C: access token (audience = this MCP server)
C->>M: POST /mcp, Authorization: Bearer <token>
M->>M: verify signature, issuer, audience, expiry, scopes
M-->>C: result
The parts worth remembering:
| Piece | Why it's there |
|---|---|
Protected Resource Metadata (/.well-known/oauth-protected-resource) |
Tells any client which authorization server to use. No per-client setup |
| PKCE | Required. Stops a stolen authorization code being exchanged by someone else |
| Resource indicators / audience | The token names this server. A token minted for another service is rejected |
| Client ID Metadata Documents | How a client identifies itself without pre-registering. Dynamic Client Registration is deprecated in 2026-07-28 |
| Scopes | Map to tool permissions: orders:read for lookups, orders:refund for refunds |
In the Python SDK you plug in a token verifier and auth settings; the SDK then serves the metadata endpoint and rejects requests without a valid token:
# no-run — a sketch: verify_jwt is your JWT check (e.g. with PyJWT against the issuer's JWKS)
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
class JWTVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
claims = verify_jwt(token, issuer="https://auth.example.com",
audience="https://mcp.example.com/mcp") # reject tokens meant for others
if claims is None:
return None
return AccessToken(token=token, client_id=claims["azp"], subject=claims["sub"],
scopes=claims["scope"].split(), expires_at=claims["exp"],
resource="https://mcp.example.com/mcp")
mcp = MCPServer(
"orders",
token_verifier=JWTVerifier(),
auth=AuthSettings(issuer_url="https://auth.example.com",
resource_server_url="https://mcp.example.com/mcp",
validate_token_resource=True, # refuse tokens issued for other resources
required_scopes=["orders:read"]),
)
With a stub verifier, here's what that server really sends to a client that has no token, and the metadata
document the client fetches next (mcp 2.3):
POST /mcp -> 401
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
GET /.well-known/oauth-protected-resource/mcp -> 200
{'resource': 'https://mcp.example.com/mcp', 'authorization_servers': ['https://auth.example.com'], 'scopes_supported': ['orders:read'], 'bearer_methods_supported': ['header']}
Then check the finer-grained scopes inside the tool that needs them (refunds require orders:refund), and
authorise against the end user in the token, not a shared service account.
Never pass the user's token through
The MCP server must not forward the token it received to downstream APIs ("token passthrough"). The spec forbids it. That token was issued for the MCP server; reusing it elsewhere breaks audience checks, audit trails and rate limits, and turns your server into a confused deputy that other services trust blindly. To call a downstream API on the user's behalf, get a separate token for that API (OAuth token exchange, or the downstream service's own OAuth flow).
For stdio servers, OAuth doesn't apply: the server runs as the user and reads credentials from its environment
(the env block of the host config). Keep those scoped: a read-only database user, a fine-grained GitHub token.
5.6 Production checklist¶
| Area | Do |
|---|---|
| Transport | Streamable HTTP over HTTPS only; validate the Origin header to block DNS-rebinding from browsers; bind local dev servers to 127.0.0.1, not 0.0.0.0 |
| Auth | OAuth 2.1 with audience checks; scopes per tool; no token passthrough |
| Limits | Request body size limits, per-user and per-tool rate limits (use Mcp-Name), timeouts on every downstream call |
| State | None in memory; handles + Redis/DB; idempotency keys on writes |
| Observability | Log tool name, user, latency, isError for every call; OpenTelemetry traces (the spec defines traceparent in _meta) |
| Versioning | Additive changes only to tool schemas; new tool name for breaking changes; stable tool order |
| Errors | ToolError with safe messages; never return stack traces or internal hostnames |
Interview questions¶
stdio vs Streamable HTTP: when do you use each?
stdio for local servers the host starts as a child process: files, git, local databases, developer tools. They run as the user and need no network auth. Streamable HTTP for remote, shared servers: company APIs and SaaS integrations used by many users and hosts. They need OAuth, rate limits and normal web-service operations.
How would you deploy an MCP server to handle heavy traffic?
As a stateless HTTP service behind a load balancer: several replicas, no in-memory session state (state in Redis
or a database, passed around as handles), idempotent write tools, HTTPS, OAuth at the edge or in the server,
rate limits by user and tool using the Mcp-Name header, timeouts on downstream calls, and tracing. The
2026-07-28 spec removed sessions to make exactly this possible.
How does authorization work for a remote MCP server?
The server is an OAuth 2.1 resource server. An unauthenticated request gets 401 pointing to Protected Resource Metadata, which names the authorization server. The client runs authorization code + PKCE, the user logs in and consents, and the client sends the access token as a bearer token. The server validates issuer, audience, expiry and scopes on every request and authorises each tool call for that user.
What is token passthrough and why is it forbidden?
Forwarding the access token the MCP server received to a downstream API. The token's audience is the MCP server, so the downstream API can't safely trust it, and it bypasses that API's own controls and audit trail, making the MCP server a confused deputy. The server should obtain its own token for the downstream API instead.
Why put the tool name in an HTTP header?
So infrastructure (API gateways, WAFs, load balancers) can route, rate-limit, authorise and log per tool without parsing JSON bodies. The server rejects requests where the header doesn't match the body, so it can't be used to bypass those policies.
Practice¶
- Run the page 3 server with
transport="streamable-http"and repeat thecurlcall from 5.2. - Send a
curlrequest with a wrongMcp-Nameheader and read the error code. - Sketch which OAuth scopes your own MCP server would need, and which tool requires which scope.
Next: 6. MCP in agent frameworks Back to: MCP overview · Notes overview