HTTP + SSE Transport — Remote MCP Servers
Convert your local stdio server to a deployable HTTP service. Add OAuth auth, containerize it, and connect multiple clients — all with the same tool definitions you already wrote.
- ✓Explain when stdio falls short and why HTTP transport solves the multi-client problem
- ✓Convert a FastMCP stdio server to HTTP/SSE with a one-line change
- ✓Implement OAuth 2.0 Bearer token auth including scope-based restrictions
- ✓Write a Dockerfile with health checks, build it, and run it locally
- ✓Connect Claude Code to your HTTP server and deploy to Fly.io, Cloud Run, or a VPS
1. Why stdio Isn't Enough
BEFORE: Imagine your team communicates with walkie-talkies. Each person presses a button, speaks directly to one other person, and that conversation is private and immediate. It works perfectly for one-to-one communication in the same physical space.
PAIN: Now your team grows to 20 people, some working remotely. You can't hand everyone a personal walkie-talkie tuned to your frequency — and even if you could, you'd need a separate device for each conversation. Walkie-talkies don't scale to shared broadcast.
MAPPING: stdio is the walkie-talkie. It's a direct, private channel between one host process and one server process on the same machine. HTTP is the radio station — one broadcast endpoint that any number of receivers can tune into, from anywhere, with standard equipment.
In MCP01 you built a stdio server. The host spawns it as a subprocess and they communicate through stdin/stdout pipes. This is perfect for local development, but breaks down the moment you need any of the following:
| Scenario | stdio | HTTP + SSE |
|---|---|---|
| Multiple clients sharing one server | ✗ Each client spawns a separate process | ✓ One process, many concurrent connections |
| Server runs in the cloud | ✗ Requires subprocess on same machine | ✓ Deploy anywhere with an IP/hostname |
| OAuth / token-based auth | ✗ No transport-layer auth mechanism | ✓ Standard HTTP headers carry Bearer tokens |
| Shared server for a team | ✗ Everyone needs the script locally | ✓ One URL, all team members connect |
| Web app / browser client | ✗ Browsers can't spawn subprocesses | ✓ Native HTTP + EventSource API |
2. HTTP + SSE Architecture
SSEServer-Sent Events (SSE) — an HTTP-based protocol where a client opens a long-lived GET connection and the server pushes newline-delimited text events over it. Each event is prefixed with data:. Unlike WebSockets, SSE is unidirectional (server→client) and rides plain HTTP/1.1, requiring no protocol upgrade. is a W3C standard where a client opens a persistent HTTP GET connection and the server pushes text/event-stream messages as they arrive. Each message is a line starting with data:. The connection stays open until the client closes it or the server sends a blank line.
In MCP's HTTP transport: the client POSTs requests to /messages (or /mcp in Streamable HTTP) and the server streams responses back via SSE on /sse. Why SSE instead of WebSockets? SSE is simpler (no handshake upgrade), works through every HTTP proxy and corporate firewall, requires no special server infrastructure, and the browser EventSource API handles reconnection automatically.
The Full Request/Response Cycle
Here is what the raw HTTP traffic looks like for a single tool call over the HTTP transport:
The client makes a long-lived GET request. The server holds this connection open and sends events down it as they occur.
GET /sse HTTP/1.1
Host: localhost:8000
Accept: text/event-stream
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Cache-Control: no-cache
--- Server response (headers only, connection stays open) ---
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no
Transfer-Encoding: chunked
data: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
The client makes a separate POST request for each JSON-RPC message. The POST returns 202 Accepted immediately — the actual result comes back over the SSE channel.
POST /messages HTTP/1.1
Host: localhost:8000
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "search_documents",
"arguments": { "query": "MCP transport", "limit": 3 }
}
}
--- Server response (immediate acknowledgement) ---
HTTP/1.1 202 Accepted
--- Result arrives on the SSE channel (milliseconds later) ---
data: {"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"[{\"id\":\"transport-docs\"..."}],"isError":false}}
Beginners expect the POST to return the tool result in its response body. It doesn't. The POST only acknowledges receipt (202). The result travels on the persistent SSE channel identified by a session ID. If you lose the SSE connection, you lose pending results.
3. Converting a stdio Server to HTTP
FastMCP abstracts the transport entirely. The only change between a stdio server and an HTTP server is the argument to mcp.run(). Every tool, every resource, every prompt stays identical.
mcp.run() defaults to stdio. Passing transport="streamable-http" switches to HTTP. Add host and port to control where it binds. The server now exposes /mcp (POST endpoint) and /sse (event stream) automatically.
MCP originally shipped a transport called "sse". The newer "streamable-http" transport adds response streaming and is the current standard. Use "streamable-http" for all new servers — it's backward compatible with clients that understand the older SSE mode and is the transport Claude.ai uses.
host="0.0.0.0" is required inside DockerThe default host="127.0.0.1" only listens on loopback. Inside a Docker container, this means the port is unreachable from outside. Always set host="0.0.0.0" when you plan to containerize or deploy to cloud infrastructure.
# document_server_http.py — same 3 tools as MCP01, HTTP transport added
from __future__ import annotations
import math
import os
from mcp.server.fastmcp import FastMCP
# ── All configuration via environment variables ──
PORT = int(os.getenv("MCP_PORT", "8000"))
HOST = os.getenv("MCP_HOST", "0.0.0.0") # 0.0.0.0 required for Docker
mcp = FastMCP(
"document-server",
# Stateless mode is ideal for multi-client HTTP servers.
# Each request is independent — no session state carried between calls.
stateless_http=True,
)
# ── In-memory document store (same as MCP01) ──
DOCUMENTS: dict[str, str] = {
"mcp-overview": "MCP is an open protocol for connecting AI to tools.",
"fastmcp-guide": "FastMCP reduces MCP server boilerplate with decorators.",
"transport-docs": "stdio is local; HTTP/SSE is for shared, remote servers.",
}
NOTES: dict[str, str] = {}
@mcp.tool()
def search_documents(query: str, limit: int = 5) -> list[dict]:
"""Search the document store for documents matching a query string."""
if not query or not query.strip():
raise ValueError("query must be a non-empty string")
if limit < 1 or limit > 50:
raise ValueError("limit must be between 1 and 50")
q = query.lower()
results = []
for doc_id, content in DOCUMENTS.items():
score = (2 if q in doc_id.lower() else 0) + (1 if q in content.lower() else 0)
if score > 0:
results.append({"id": doc_id, "preview": content[:120], "score": score})
results.sort(key=lambda r: r["score"], reverse=True)
return results[:limit]
@mcp.tool()
def calculate_similarity(text_a: str, text_b: str) -> dict:
"""Calculate cosine similarity between two texts using word overlap."""
if not text_a or not text_b:
raise ValueError("Both text_a and text_b must be non-empty")
def freq(t: str) -> dict:
d: dict = {}
for w in t.lower().split():
w = w.strip(".,!?;:")
if w: d[w] = d.get(w, 0) + 1
return d
fa, fb = freq(text_a), freq(text_b)
words = set(fa) | set(fb)
dot = sum(fa.get(w,0)*fb.get(w,0) for w in words)
mag = (math.sqrt(sum(v**2 for v in fa.values())) *
math.sqrt(sum(v**2 for v in fb.values())))
sim = round(dot/mag, 4) if mag else 0.0
return {"similarity": sim, "common_words": sorted(set(fa)&set(fb))[:10],
"interpretation": "highly similar" if sim>0.7 else "somewhat related" if sim>0.3 else "largely different"}
@mcp.tool()
def save_note(title: str, content: str) -> dict:
"""Save a note. Notes persist for the server process lifetime."""
if not title or len(title) > 100:
raise ValueError("title must be 1–100 characters")
if not content or len(content) > 5000:
raise ValueError("content must be 1–5000 characters")
NOTES[title.strip()] = content
return {"success": True, "title": title.strip(), "total_notes": len(NOTES)}
# ── Health check endpoint (required for Docker / cloud health probes) ──
@mcp.custom_server.get("/health")
async def health():
return {"status": "ok", "server": "document-server", "tools": 3}
if __name__ == "__main__":
# stdio (MCP01 style):
# mcp.run()
# HTTP/SSE — change is here only, tools are untouched:
mcp.run(transport="streamable-http", host=HOST, port=PORT)
# Server now exposes:
# POST http://HOST:PORT/mcp — JSON-RPC messages
# GET http://HOST:PORT/sse — SSE event stream
# GET http://HOST:PORT/health — health check
// documentServerHttp.ts — stdio → HTTP in the TypeScript SDK
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import express from "express";
const PORT = parseInt(process.env.MCP_PORT ?? "8000", 10);
// ── Express app wraps the MCP transport ──
const app = express();
app.use(express.json());
// ── Health check (required for Docker / cloud probes) ──
app.get("/health", (_req, res) => {
res.json({ status: "ok", server: "document-server", tools: 3 });
});
// ── MCP Server (same tool definitions as MCP01) ──
const server = new McpServer({ name: "document-server", version: "1.0.0" });
const DOCUMENTS: Record = {
"mcp-overview": "MCP is an open protocol for connecting AI to tools.",
"fastmcp-guide": "FastMCP reduces MCP server boilerplate with decorators.",
"transport-docs": "stdio is local; HTTP/SSE is for shared, remote servers.",
};
const NOTES: Record = {};
server.tool(
"search_documents",
"Search the document store for documents matching a query string.",
{ query: z.string().min(1), limit: z.number().int().min(1).max(50).default(5) },
async ({ query, limit }) => {
const q = query.toLowerCase();
const results = Object.entries(DOCUMENTS)
.map(([id, content]) => ({
id,
preview: content.slice(0, 120),
score: (id.toLowerCase().includes(q) ? 2 : 0) + (content.toLowerCase().includes(q) ? 1 : 0),
}))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return { content: [{ type: "text" as const, text: JSON.stringify(results) }] };
}
);
server.tool(
"save_note",
"Save a note. Notes persist for the server process lifetime.",
{
title: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
},
async ({ title, content }) => {
const key = title.trim();
NOTES[key] = content;
return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, title: key, total_notes: Object.keys(NOTES).length }) }] };
}
);
// ── HTTP transport: POST /mcp + GET /sse ──
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
});
// Mount MCP endpoints on Express
app.post("/mcp", async (req, res) => {
await transport.handleRequest(req, res, req.body);
});
app.get("/sse", async (req, res) => {
await transport.handleSseRequest(req, res);
});
app.delete("/mcp", async (req, res) => {
await transport.handleRequest(req, res, req.body);
});
// Connect server to transport, then start listening
await server.connect(transport);
app.listen(PORT, "0.0.0.0", () => {
console.log(`MCP server listening on http://0.0.0.0:${PORT}`);
console.log(" POST /mcp — JSON-RPC messages");
console.log(" GET /sse — SSE event stream");
console.log(" GET /health — health check");
});
Quick smoke test with curl
curl http://localhost:8000/health- One argument changed —
mcp.run()→mcp.run(transport="streamable-http", host="0.0.0.0", port=8000). All three tools are identical. - Two endpoints auto-created —
POST /mcpaccepts JSON-RPC requests;GET /ssestreams responses back to the connected client. - Health endpoint added — Cloud providers (Fly, Cloud Run, ECS) send HTTP GET probes to verify your container is alive. Without a health endpoint returning 200, the platform will restart your container.
4. OAuth 2.0 Authentication
OAuth 2.0OAuth 2.0 — an authorization framework (RFC 6749) that lets clients obtain limited access tokens from an authorization server. MCP uses the Bearer token flow: the client presents a token in the Authorization header; the resource server (your MCP server) validates it without needing to know the user's credentials. Bearer token authentication answers the question: "Is this client allowed to call my MCP server?" The MCP specification mandates OAuth 2.0 for all HTTP transports exposed to untrusted clients. The flow has four parts: a token endpoint (to issue tokens during development), a middleware that validates the Authorization: Bearer <token> header, scope-based restrictions (read-only vs read-write clients), and a client that passes the token with every request.
- Token generation endpoint (dev/test — use Auth0/Okta/Cognito in production)
- Bearer token validation middleware that runs on every request
- Scope extraction:
mcp:readallows tools/list;mcp:writeallows tools/call - Client-side header:
Authorization: Bearer <token>
API keys are static and require a lookup table. Bearer tokens (especially JWTs) are self-contained — the server validates the token signature without a database lookup, and scopes are embedded in the token payload. This makes them cheaper at scale and easier to rotate.
The POST /token endpoint shown here is for local development and testing. In production, delegate token issuance to an identity provider (Auth0, AWS Cognito, Google IAM). Never run a secret-based token endpoint in production without rate limiting and HTTPS.
# auth_middleware.py — Bearer token auth for FastMCP HTTP server
# pip install fastapi python-jose[cryptography] uvicorn
from __future__ import annotations
import os
import time
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
SECRET_KEY = os.environ["JWT_SECRET"] # set in env, never hardcode
ALGORITHM = "HS256"
TOKEN_EXPIRE_SECONDS = 3600
app = FastAPI()
bearer_scheme = HTTPBearer()
# ── Token issuance (dev/test only — replace with real IdP in production) ──
@app.post("/token")
async def issue_token(client_id: str, scope: str = "mcp:read"):
"""
Issue a JWT for development.
scope values: "mcp:read" — can only list tools
"mcp:write" — can list + call tools
In production: use Auth0 / AWS Cognito / Google IAM here instead.
"""
allowed_scopes = {"mcp:read", "mcp:write"}
if scope not in allowed_scopes:
raise HTTPException(400, f"Invalid scope. Allowed: {allowed_scopes}")
payload = {
"sub": client_id,
"scope": scope,
"iat": int(time.time()),
"exp": int(time.time()) + TOKEN_EXPIRE_SECONDS,
}
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": token, "token_type": "bearer", "expires_in": TOKEN_EXPIRE_SECONDS}
# ── Token validation dependency ──
def verify_token(
credentials: Annotated[HTTPAuthorizationCredentials, Security(bearer_scheme)]
) -> dict:
"""FastAPI dependency: validate the Bearer token and return the payload."""
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError as exc:
raise HTTPException(
status_code=401,
detail=f"Invalid or expired token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
# ── Scope-based restriction dependency ──
def require_write_scope(token_payload: Annotated[dict, Depends(verify_token)]) -> dict:
"""Only allow clients with mcp:write scope to mutate state."""
if "mcp:write" not in token_payload.get("scope", ""):
raise HTTPException(
status_code=403,
detail="This operation requires mcp:write scope",
)
return token_payload
# ── Protected MCP endpoint ──
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("document-server", stateless_http=True)
# Attach FastMCP to FastAPI — both share the same app
# Requests to /mcp and /sse are guarded by verify_token
@app.post("/mcp")
async def mcp_post(request: Request, _tok: Annotated[dict, Depends(verify_token)]):
return await mcp.handle_streamable_http(request)
@app.get("/sse")
async def mcp_sse(request: Request, _tok: Annotated[dict, Depends(verify_token)]):
return await mcp.handle_sse(request)
@app.get("/health")
async def health():
return {"status": "ok"}
# ── Tools (same as before, write tool checks write scope) ──
@mcp.tool()
async def save_note_authed(
title: str, content: str, request: Request
) -> dict:
"""Save a note (requires mcp:write scope)."""
# Extract token from request headers and check scope
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise PermissionError("Authorization header required")
token_str = auth.removeprefix("Bearer ")
try:
payload = jwt.decode(token_str, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise PermissionError("Invalid token")
if "mcp:write" not in payload.get("scope", ""):
raise PermissionError("mcp:write scope required to save notes")
NOTES[title.strip()] = content
return {"success": True, "title": title.strip()}
NOTES: dict[str, str] = {}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
// authMiddleware.ts — Bearer token auth for Express MCP server
// npm install jsonwebtoken @types/jsonwebtoken
import express, { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
const SECRET_KEY = process.env.JWT_SECRET!; // set in env, never hardcode
const TOKEN_EXPIRE = "1h";
// ── Token interface ──
interface TokenPayload {
sub: string;
scope: string;
iat: number;
exp: number;
}
// ── Token issuance (dev/test only) ──
export function addTokenEndpoint(app: express.Application): void {
app.post("/token", express.urlencoded({ extended: false }), (req, res) => {
const { client_id, scope = "mcp:read" } = req.body as { client_id: string; scope?: string };
const allowed = new Set(["mcp:read", "mcp:write"]);
if (!allowed.has(scope)) {
res.status(400).json({ error: `Invalid scope. Allowed: ${[...allowed].join(", ")}` });
return;
}
const token = jwt.sign({ sub: client_id, scope }, SECRET_KEY, { expiresIn: TOKEN_EXPIRE });
res.json({ access_token: token, token_type: "bearer" });
});
}
// ── Bearer token validation middleware ──
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing Bearer token" });
return;
}
try {
const payload = jwt.verify(auth.slice(7), SECRET_KEY) as TokenPayload;
(req as Request & { tokenPayload: TokenPayload }).tokenPayload = payload;
next();
} catch (err) {
res.status(401).json({ error: `Invalid or expired token: ${err instanceof Error ? err.message : err}` });
}
}
// ── Scope restriction middleware factory ──
export function requireScope(scope: string) {
return (req: Request, res: Response, next: NextFunction): void => {
const payload = (req as Request & { tokenPayload?: TokenPayload }).tokenPayload;
if (!payload?.scope.includes(scope)) {
res.status(403).json({ error: `This operation requires ${scope} scope` });
return;
}
next();
};
}
// ── Usage in your server ──
// import { addTokenEndpoint, requireAuth, requireScope } from "./authMiddleware.js";
//
// addTokenEndpoint(app);
// app.post("/mcp", requireAuth, async (req, res) => { ... });
// app.get("/sse", requireAuth, async (req, res) => { ... });
//
// curl -X POST http://localhost:8000/token \
// -d "client_id=alice&scope=mcp:write"
// curl http://localhost:8000/sse \
// -H "Authorization: Bearer "
- Token issuance —
POST /tokensigns a JWT with the client's ID and requested scope. In production this endpoint is replaced by Auth0/Cognito/Google IAM. - Middleware intercepts every request —
verify_token/requireAuthdecodes and validates the JWT before any tool code runs. Invalid token = 401, no further processing. - Scope restriction —
mcp:readclients can list tools butsave_note(a write operation) checks formcp:writescope and raisesPermissionErrorotherwise. This becomes anisError: truetool result the LLM reads.
5. Containerizing the MCP Server
Docker makes your server reproducible. The same image that runs on your laptop will run on Fly.io, Cloud Run, and DigitalOcean without modification. Two files needed: Dockerfile and docker-compose.yml.
Stage 1 installs dependencies. Stage 2 copies only the app code — no build tools, no pip cache, no test files. This keeps the final image small (typically 150–200 MB for a Python server vs 900 MB+ for a non-staged build).
Cloud platforms (Cloud Run, ECS, Fly.io, Kubernetes) send periodic HTTP probes to your /health endpoint. If the probe fails three times, the platform kills and restarts the container. Without a health check the platform has no way to know your server is alive — it may route traffic to a crashed instance.
ENV JWT_SECRET=my-secret in a Dockerfile bakes the secret into the image layer history — it will appear in docker history and in any registry the image is pushed to. Always pass secrets via environment variables at runtime (docker run -e JWT_SECRET=...) or via a secrets manager.
# ── Stage 1: dependency installation ──
FROM python:3.12-slim AS builder
WORKDIR /build
# Copy and install dependencies first (layer caches if requirements unchanged)
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: minimal runtime image ──
FROM python:3.12-slim
WORKDIR /app
# Copy installed packages from builder stage
COPY --from=builder /install /usr/local
# Copy application code
COPY document_server_http.py .
# If you added auth_middleware.py, copy it too:
# COPY auth_middleware.py .
# Expose the server port (documentation only — does not publish the port)
EXPOSE 8000
# Health check: cloud platforms probe this endpoint every 10s
# If it fails 3 times in a row, the container is restarted
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" \
|| exit 1
# Secrets via environment variables (never hardcode here)
# Set at runtime: docker run -e JWT_SECRET=... -e MCP_PORT=8000
ENV MCP_HOST=0.0.0.0
ENV MCP_PORT=8000
# Run as non-root user (security best practice)
RUN useradd -m mcpuser && chown -R mcpuser /app
USER mcpuser
CMD ["python", "document_server_http.py"]
# ── Stage 1: build TypeScript → JavaScript ──
FROM node:20-slim AS builder
WORKDIR /build
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
RUN npx tsc --outDir dist
# ── Stage 2: minimal runtime image ──
FROM node:20-slim
WORKDIR /app
# Copy compiled JS and node_modules from builder
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD node -e "fetch('http://localhost:8000/health').then(r=>r.ok||process.exit(1)).catch(()=>process.exit(1))" \
|| exit 1
ENV MCP_HOST=0.0.0.0
ENV MCP_PORT=8000
RUN useradd -m mcpuser && chown -R mcpuser /app
USER mcpuser
CMD ["node", "dist/documentServerHttp.js"]
docker-compose.yml — server + test client
version: "3.9"
services:
mcp-server:
build: .
ports:
- "8000:8000"
environment:
MCP_PORT: "8000"
MCP_HOST: "0.0.0.0"
JWT_SECRET: "${JWT_SECRET}" # loaded from .env file or shell
ALLOWED_ORIGINS: "http://localhost:3000"
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
restart: unless-stopped
# Simple curl-based smoke test client
mcp-test:
image: curlimages/curl:latest
depends_on:
mcp-server:
condition: service_healthy
command: >
sh -c "
echo '--- Health check ---' &&
curl -sf http://mcp-server:8000/health &&
echo '' &&
echo '--- Get token ---' &&
TOKEN=$(curl -sf -X POST http://mcp-server:8000/token
-H 'Content-Type: application/x-www-form-urlencoded'
-d 'client_id=test&scope=mcp:write') &&
echo $TOKEN
"
profiles:
- test # only runs with: docker compose --profile test up
docker build -t mcp-document-server . && docker run -p 8000:8000 -e JWT_SECRET=dev-secret-change-me mcp-document-serverYour server is now a portable container. The multi-stage build keeps the image lean. Non-root user prevents privilege escalation. Health check lets cloud platforms verify liveness. The same image runs locally with docker run and in the cloud with fly deploy or gcloud run deploy — no changes needed.
6. CORS Configuration
CORSCross-Origin Resource Sharing (CORS) — a browser security mechanism that restricts web pages from making requests to a different domain than the one that served the page. The browser sends an Origin header; the server must respond with matching Access-Control-Allow-Origin headers or the browser blocks the response. (Cross-Origin Resource Sharing) only matters for browser-based clients — Claude Desktop, Claude Code, and custom Python clients ignore it. But if you're building a web app that connects to your MCP server, or exposing the server to claude.ai, CORS headers are required.
* in Production
Access-Control-Allow-Origin: * allows any website to send requests to your MCP server. Combined with OAuth tokens stored in browser localStorage (which are readable by any JS on the page), this creates a cross-site request forgery vector. Always enumerate specific allowed origins in production.
# Add to your FastAPI app (or FastMCP's underlying Starlette app)
import os
from fastapi.middleware.cors import CORSMiddleware
# Read allowed origins from environment variable (comma-separated)
# Example: ALLOWED_ORIGINS=https://app.example.com,https://staging.example.com
raw_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
ALLOWED_ORIGINS = [o.strip() for o in raw_origins.split(",") if o.strip()]
# For FastMCP, access the underlying Starlette app:
# mcp._app is the Starlette app FastMCP wraps
fastapi_app = mcp.streamable_http_app()
fastapi_app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, # specific origins only, never "*"
allow_credentials=True, # required for Authorization header
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Session-Id"],
max_age=3600, # preflight cache TTL in seconds
)
# Example CORS headers sent to an allowed origin:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Credentials: true
# Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
# Access-Control-Allow-Headers: Authorization, Content-Type, X-Session-Id
# Example: blocking an unrecognized origin
# Browser sends: Origin: https://evil.example.com
# Server responds with NO Access-Control-Allow-Origin header
# Browser: blocks the response, logs "CORS error"
// npm install cors @types/cors
import cors from "cors";
const rawOrigins = process.env.ALLOWED_ORIGINS ?? "http://localhost:3000";
const ALLOWED_ORIGINS = rawOrigins.split(",").map((o) => o.trim()).filter(Boolean);
// Apply before all routes
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl, Postman)
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
callback(new Error(`CORS: origin '${origin}' is not allowed`));
},
credentials: true, // required for Authorization header
methods: ["GET", "POST", "DELETE", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"],
maxAge: 3600, // preflight cache TTL
})
);
// Verify your CORS config at startup
console.log("CORS allowed origins:", ALLOWED_ORIGINS);
Origin header and are not subject to CORS restrictions. CORS is only relevant when a web page in a browser is making the MCP connection.
7. Connecting a Client to an HTTP Server
HTTP MCP servers use a URL instead of a command in the client config. Both Claude Desktop and Claude Code support this with the url key. The headers key passes the Authorization: Bearer token.
For stdio servers you specified "command" and "args". For HTTP servers you specify "url" pointing at the SSE endpoint. The MCP client library handles all HTTP and SSE connection management automatically.
/sse, not /mcpClaude Desktop and Claude Code connect by opening the SSE channel first. Use the /sse endpoint in your config URL. Using /mcp (the POST endpoint) will cause a connection failure — it expects SSE headers, not a plain POST response.
Claude Desktop
{
"mcpServers": {
"my-remote-server": {
"url": "http://localhost:8000/sse",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}
// For a deployed server (replace with your actual URL):
// {
// "mcpServers": {
// "my-remote-server": {
// "url": "https://mcp.example.com/sse",
// "headers": {
// "Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9..."
// }
// }
// }
// }
// macOS config path:
// ~/Library/Application Support/Claude/claude_desktop_config.json
// Windows:
// %APPDATA%\Claude\claude_desktop_config.json
# custom_client.py — Python MCP client connecting over HTTP/SSE
# pip install mcp httpx
import asyncio
from mcp import ClientSession
from mcp.client.sse import sse_client
SERVER_URL = "http://localhost:8000/sse"
TOKEN = "YOUR_TOKEN_HERE" # obtain from POST /token in production
async def main():
headers = {"Authorization": f"Bearer {TOKEN}"}
# Open the SSE connection with auth headers
async with sse_client(SERVER_URL, headers=headers) as (read, write):
async with ClientSession(read, write) as session:
# Perform capability negotiation
await session.initialize()
# List available tools
tools = await session.list_tools()
print("Available tools:")
for tool in tools.tools:
print(f" {tool.name}: {tool.description}")
# Call a tool
result = await session.call_tool(
"search_documents",
arguments={"query": "MCP", "limit": 3}
)
print("\nsearch_documents result:")
for block in result.content:
print(block.text)
if __name__ == "__main__":
asyncio.run(main())
// customClient.ts — TypeScript MCP client connecting over HTTP/SSE
// npm install @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const SERVER_URL = "http://localhost:8000/sse";
const TOKEN = process.env.MCP_TOKEN ?? "YOUR_TOKEN_HERE";
async function main() {
const transport = new SSEClientTransport(
new URL(SERVER_URL),
{
// Pass auth headers with the SSE connection request
requestInit: {
headers: { Authorization: `Bearer ${TOKEN}` },
},
}
);
const client = new Client(
{ name: "custom-client", version: "1.0.0" },
{ capabilities: {} }
);
try {
await client.connect(transport);
// List tools
const { tools } = await client.listTools();
console.log("Available tools:");
tools.forEach((t) => console.log(` ${t.name}: ${t.description}`));
// Call a tool
const result = await client.callTool({
name: "search_documents",
arguments: { query: "MCP", limit: 3 },
});
console.log("\nsearch_documents result:");
result.content.forEach((b) => {
if (b.type === "text") console.log(b.text);
});
} finally {
await client.close();
}
}
main().catch(console.error);
Claude Code — .claude/settings.json
{
"mcpServers": {
"document-server": {
"url": "http://localhost:8000/sse",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}
// For a team-shared server at a real hostname:
// {
// "mcpServers": {
// "document-server": {
// "url": "https://mcp.yourcompany.com/sse",
// "headers": {
// "Authorization": "Bearer ${MCP_TOKEN}"
// }
// }
// }
// }
// Set MCP_TOKEN in your shell profile or .env file.
// Claude Code expands ${ENV_VAR} references in settings.json.
The client config went from "command": "python" + "args" (local subprocess) to "url": "http://..." + "headers" (HTTP connection). The tools available to Claude are identical — only the transport changed. Any team member who adds this URL to their settings.json immediately gets all three tools without installing anything locally.
8. Deploy to Cloud
Your Docker image is the deployment artifact. The same image runs on Fly.io, Google Cloud Run, and any VPS. Pick the platform that matches your needs.
Simplest. Free tier. Global edge deployment. One CLI command. Best for: personal tools and small teams.
Scales to zero. Pay-per-request. Google-managed TLS. Best for: cost-sensitive production workloads.
Full control. Predictable pricing. Persistent storage easy. Best for: teams that want no cloud lock-in.
All three deploy the same image. The only difference is the CLI commands and where TLS terminates. Fly.io and Cloud Run handle TLS automatically. On a VPS you manage TLS yourself (nginx + Let's Encrypt).
Bearer tokens sent over plain HTTP are visible to anyone on the network path. Cloud Run and Fly.io auto-provision TLS certificates. On a VPS, set up nginx as a reverse proxy with a Let's Encrypt certificate before exposing your server to the internet.
# 1. Install flyctl and authenticate
curl -L https://fly.io/install.sh | sh
fly auth login
# 2. Initialise the app (run once, generates fly.toml)
fly launch --name mcp-document-server --dockerfile Dockerfile \
--region lax --no-deploy
# 3. Set the JWT secret as a Fly secret (stored encrypted, never in toml)
fly secrets set JWT_SECRET="your-production-secret-here"
# 4. Deploy
fly deploy
# 5. Get your server URL
fly statusapp = "mcp-document-server"
primary_region = "lax"
[build]
dockerfile = "Dockerfile"
[env]
MCP_PORT = "8080" # Fly uses 8080 internally
MCP_HOST = "0.0.0.0"
ALLOWED_ORIGINS = "https://yourapp.example.com"
# JWT_SECRET set via: fly secrets set JWT_SECRET=...
[[services]]
internal_port = 8080
protocol = "tcp"
[[services.ports]]
port = 443
handlers = ["tls", "http"] # TLS termination by Fly
[[services.http_checks]]
path = "/health"
interval = "10s"
timeout = "5s"
# 1. Authenticate and set project
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
# 2. Build and push to Google Artifact Registry
gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/mcp-document-server:latest
# 3. Deploy to Cloud Run (scales to zero when idle — pay only for requests)
gcloud run deploy mcp-document-server \
--image gcr.io/YOUR_PROJECT_ID/mcp-document-server:latest \
--platform managed \
--region us-central1 \
--port 8000 \
--allow-unauthenticated \
--set-env-vars MCP_HOST=0.0.0.0,MCP_PORT=8000 \
--set-secrets JWT_SECRET=mcp-jwt-secret:latest \
--min-instances 0 \
--max-instances 10
# 4. Get the deployed URL
gcloud run services describe mcp-document-server \
--region us-central1 \
--format "value(status.url)"# ── On your VPS (Ubuntu 22.04) ──
# 1. Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER
# 2. Copy image (option A: push to Docker Hub)
docker tag mcp-document-server YOUR_DOCKERHUB_USER/mcp-document-server
docker push YOUR_DOCKERHUB_USER/mcp-document-server
# On VPS:
docker pull YOUR_DOCKERHUB_USER/mcp-document-server
# 3. Run the container
docker run -d \
--name mcp-server \
--restart unless-stopped \
-p 127.0.0.1:8000:8000 \ # bind to loopback only — nginx proxies
-e JWT_SECRET="your-production-secret" \
-e MCP_HOST=0.0.0.0 \
-e MCP_PORT=8000 \
YOUR_DOCKERHUB_USER/mcp-document-server
# 4. Install nginx + certbot for TLS
apt install nginx certbot python3-certbot-nginx
certbot --nginx -d mcp.yourdomain.com
# 5. nginx config snippet (add to /etc/nginx/sites-available/mcp)
# server {
# listen 443 ssl;
# server_name mcp.yourdomain.com;
#
# location / {
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header X-Real-IP $remote_addr;
# # Required for SSE:
# proxy_buffering off;
# proxy_cache off;
# proxy_set_header Connection '';
# proxy_http_version 1.1;
# chunked_transfer_encoding on;
# }
# }proxy_buffering off, proxy_cache off, and chunked_transfer_encoding on directives are mandatory — without them, SSE events will queue in nginx and clients will see long delays or connection timeouts.
Production Checklist
| Item | Why Required | How |
|---|---|---|
| TLS (HTTPS) | Bearer tokens over HTTP are visible to network eavesdroppers | Fly.io / Cloud Run: automatic. VPS: nginx + Let's Encrypt |
| Secrets management | JWT_SECRET in env files = credential leak risk | Fly secrets, GCP Secret Manager, or HashiCorp Vault |
| Rate limiting | Unauthenticated token endpoint can be brute-forced | nginx rate_limit, Cloud Armor, or Fly.io rate limiting |
| Health check endpoint | Platform needs liveness signal to avoid routing to crashed container | GET /health returning 200 (already implemented) |
| Request logging | Debugging tool call failures requires a log trail | structlog (Python) or pino (Node.js) with JSON output |
9. Lab: Deploy Your First HTTP MCP Server
End-to-end: take MCP01's 3-tool server, convert it to HTTP, run it in Docker, and connect Claude Code. Five steps, each with code, command, and a checkpoint.
Convert mcp.run() to HTTP transport
Open your document_server.py from MCP01. The only required change is the last two lines:
# Remove this line:
# mcp.run()
# Add these lines:
import os
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host=os.getenv("MCP_HOST", "0.0.0.0"),
port=int(os.getenv("MCP_PORT", "8000")),
)
Run python document_server.py and curl the health endpoint: curl http://localhost:8000/health. You should see {"status":"ok"}.
Write the Dockerfile
Create Dockerfile in the same directory as your server:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY document_server.py .
EXPOSE 8000
HEALTHCHECK CMD python -c \
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" \
|| exit 1
ENV MCP_HOST=0.0.0.0
ENV MCP_PORT=8000
CMD ["python", "document_server.py"]
Also create requirements.txt:
mcp>=1.0
uvicorn>=0.29
Run docker build -t mcp-lab .. You should see Successfully built ... with no errors. The image should be under 300 MB: docker image ls mcp-lab.
docker build && docker run
docker run -d --name mcp-lab -p 8000:8000 mcp-lab && curl http://localhost:8000/healthThe health check returns 200 and the container is running. Verify: docker ps --filter name=mcp-lab — STATUS should show healthy after about 30 seconds.
Update .claude/settings.json
In your project root, edit (or create) .claude/settings.json:
{
"mcpServers": {
"document-server": {
"url": "http://localhost:8000/sse"
}
}
}
No auth headers needed for a local-only dev server. In production, add "headers": {"Authorization": "Bearer TOKEN"}.
The settings.json file is saved. No restart needed for Claude Code — it reads settings on session start.
Test from Claude Code
claudeClaude Code found your tool, called it over HTTP, and returned a real result from your containerized server. The same server is now ready to deploy to Fly.io or Cloud Run with the commands in Section 8.
docker stop mcp-lab && docker rm mcp-lab to remove the running container.
Knowledge Check
Five questions covering HTTP transport, auth, and deployment.
1. Which part of the HTTP + SSE request cycle delivers the actual tool result to the client?
/messagesdata: event pushed on the persistent SSE channel opened with GET /sse2. What single argument to mcp.run() converts a FastMCP server from stdio to HTTP transport?
transport="websocket"transport="streamable-http"mode="http"protocol="sse"3. Your Docker MCP server is running behind nginx. SSE events are delivered with a 30-second delay. What nginx directive is missing?
gzip on; — response compression is interferingproxy_buffering off; combined with proxy_cache off; — nginx buffers responses by default, queuing SSE events instead of flushing them immediatelykeepalive_timeout 0; — connection timeout is too shortssl_protocols TLSv1.3; — TLS version mismatch causes buffering4. A browser-based web app at https://app.example.com tries to connect to your MCP server at https://mcp.example.com/sse. The browser blocks the request with a CORS error. What is the minimal correct fix?
Access-Control-Allow-Origin: * — wildcard allows all originsAccess-Control-Allow-Origin: https://app.example.com and Access-Control-Allow-Credentials: true on the MCP server's responses5. You need a team of 10 engineers to share one deployed MCP server. Each engineer's Claude Code instance connects simultaneously. Which transport makes this possible and why?
6. You set host="127.0.0.1" in your FastMCP HTTP server and package it in Docker. The container starts but is unreachable from outside. What is the cause?
-p in the docker run command127.0.0.1 is the container's loopback — it refuses all connections from outside the container network. Use host="0.0.0.0" to bind all interfaces.