Skip to content

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)
Output
2026-07-28
{'order_id': 'A100', 'status': 'shipped', 'eta': 'Friday'}

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":{}}}}'
Response
{"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:

Response
{"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:

Response
{"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):

Response
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 the curl call from 5.2.
  • Send a curl request with a wrong Mcp-Name header 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