⌂ Home MCP Track — Building with the Model Context Protocol ⚡ MODULE 03 of 8 · MCP Track
~75 min Intermediate Lab — 80% Code
MODULE 03 · HTTP + SSE Transport

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.

After this module you will:
  • 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

Analogy — The Walkie-Talkie

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:

ScenariostdioHTTP + 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
Why It Matters A team of 5 engineers each spawning their own copy of a stdio server wastes resources and leads to drift — server version A on Alice's machine, version B on Bob's. One centrally-deployed HTTP server means everyone uses the same code, same data, and the same update simultaneously. Real production MCP deployments at companies like Anthropic, Sourcegraph, and JetBrains are all HTTP-based.
Animation 1 — stdio (single-client) vs HTTP/SSE (multi-client) Topology
stdio — Local Only
Claude Code (Alice) server.py PID 1201
Claude Code (Bob) server.py PID 2847
Claude Code (Carol) server.py PID 3512
⚠ 3 separate processes, 3× memory
HTTP/SSE — Shared Server
Claude Code (Alice)
Claude Code (Bob)
Claude Code (Carol)
http://mcp.example.com:8000
The problem is clear. Now let's understand exactly how HTTP + SSE solves it — request direction, wire format, and the raw HTTP headers involved.

2. HTTP + SSE Architecture

Technical Definition — SSE (Server-Sent Events)

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:

WHAT — Step 1: Client opens the SSE channel

The client makes a long-lived GET request. The server holds this connection open and sends events down it as they occur.

HTTP — SSE Connection Open
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":{}}

WHAT — Step 2: Client POSTs a tool call

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.

HTTP — Tool Call POST
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}}

GOTCHA — The POST and SSE are decoupled

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.

Animation 2 — HTTP + SSE Message Flow: POST Request ↔ SSE Response
MCP Client
STEP 1
GET /sse
Open SSE channel
STEP 3
POST /messages
tools/call search_documents
STEP 5
SSE data: result
isError: false
← 200 OK
← 202
MCP Server
STEP 2
200 + event-stream
Channel open, session-id assigned
STEP 4
202 + execute tool
Validates → runs fn → queues result
STEP 5
Push SSE event
data: {"id":42,"result"...}
Architecture understood. Now the fun part — converting MCP01's 3-tool server to HTTP with a single line change.

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.

WHAT — The one-line change

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.

WHY — "streamable-http" vs the older "sse" transport

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.

GOTCHA — host="0.0.0.0" is required inside Docker

The 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.

Python — document_server_http.py
# 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
TypeScript — documentServerHttp.ts
// 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

Terminal — test after starting the server
curl http://localhost:8000/health
{"status":"ok","server":"document-server","tools":3}
What Just Happened?
  • One argument changedmcp.run()mcp.run(transport="streamable-http", host="0.0.0.0", port=8000). All three tools are identical.
  • Two endpoints auto-createdPOST /mcp accepts JSON-RPC requests; GET /sse streams 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.
The server runs over HTTP now, but anyone with the URL can call it. Let's add OAuth 2.0 Bearer token auth so only authorized clients get through.

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.

Animation 3 — OAuth 2.0 Handshake: Client → Auth Server → Token → MCP Server
CLIENT
Claude Code
AUTH SERVER
POST /token
MCP SERVER
Validate Token
WHAT — Four components in the auth layer
  1. Token generation endpoint (dev/test — use Auth0/Okta/Cognito in production)
  2. Bearer token validation middleware that runs on every request
  3. Scope extraction: mcp:read allows tools/list; mcp:write allows tools/call
  4. Client-side header: Authorization: Bearer <token>
WHY — Bearer tokenBearer token — an opaque or signed string that grants access to a protected resource. The bearer (whoever presents it) gets the associated permissions. Never transmit bearer tokens over plain HTTP — always use HTTPS in production. vs API key

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.

GOTCHA — The dev token endpoint is NOT for production

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.

Python — auth_middleware.py (add to your server)
# 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)
TypeScript — authMiddleware.ts (add to your server)
// 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 "
What Just Happened?
  • Token issuancePOST /token signs a JWT with the client's ID and requested scope. In production this endpoint is replaced by Auth0/Cognito/Google IAM.
  • Middleware intercepts every requestverify_token / requireAuth decodes and validates the JWT before any tool code runs. Invalid token = 401, no further processing.
  • Scope restrictionmcp:read clients can list tools but save_note (a write operation) checks for mcp:write scope and raises PermissionError otherwise. This becomes an isError: true tool result the LLM reads.
Auth in place. Now containerize the server so it runs identically everywhere — your laptop, CI, and cloud.

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.

WHAT — Multi-stage build

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).

WHY — HEALTHCHECK is not optional

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.

GOTCHA — Never hardcode secrets in the Dockerfile

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.

Dockerfile — Python MCP Server
# ── 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"]
Dockerfile — TypeScript MCP Server
# ── 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

YAML — docker-compose.yml
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
Terminal — build and run
docker build -t mcp-document-server . && docker run -p 8000:8000 -e JWT_SECRET=dev-secret-change-me mcp-document-server
Successfully built a3f82c9e1d47 Successfully tagged mcp-document-server:latest MCP server listening on http://0.0.0.0:8000 POST /mcp — JSON-RPC messages GET /sse — SSE event stream GET /health — health check
What Just Happened?

Your 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.

Container built. Before deploying, lock down which browser origins can reach the server — that's CORS configuration.

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.

Security Warning — Never Use * 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.
Python — CORS with FastAPI CORSMiddleware
# 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"
TypeScript — CORS with cors npm package
// 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);
CORS for Claude Code and Claude Desktop Claude Code and Claude Desktop connect server-to-server (not browser-to-server). They do not send an 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.
Server secured and CORS configured. Now update the client side — Claude Desktop config and Claude Code settings.json — to point at the HTTP server.

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.

WHAT — URL-based config vs command-based config

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.

GOTCHA — The URL must point at /sse, not /mcp

Claude 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

JSON — claude_desktop_config.json (HTTP server)
{
  "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
Python — custom MCP client over HTTP
# 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())
TypeScript — custom MCP client over HTTP
// 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

JSON — .claude/settings.json (HTTP server)
{
  "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.
What Just Happened?

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.

Everything works locally. Let's deploy the Docker image to three different cloud targets — same image, different platforms.

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.

Fly.io

Simplest. Free tier. Global edge deployment. One CLI command. Best for: personal tools and small teams.

Cloud Run

Scales to zero. Pay-per-request. Google-managed TLS. Best for: cost-sensitive production workloads.

VPS (DO / Hetzner)

Full control. Predictable pricing. Persistent storage easy. Best for: teams that want no cloud lock-in.

WHAT — Three targets, same Docker image

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).

GOTCHA — Always use HTTPS in production

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.

Terminal — Fly.io deployment
# 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 status
Deploying mcp-document-server... Building image... Pushing image... Creating release v1... --> v1 deployed successfully Hostname: mcp-document-server.fly.dev # Your MCP server is now at: # https://mcp-document-server.fly.dev/sse ← SSE endpoint # https://mcp-document-server.fly.dev/health ← health check # TLS is automatic — no nginx, no certbot needed
TOML — fly.toml (generated by fly launch)
app = "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"
Terminal — Google Cloud Run deployment
# 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)"
Deploying container to Cloud Run service [mcp-document-server]... OK Deploying... Done. OK Creating Revision... OK Routing traffic... Service [mcp-document-server] revision [mcp-document-server-00001-abc] has been deployed and is serving 100 percent of traffic. Service URL: https://mcp-document-server-xxxx-uc.a.run.app # SSE endpoint: https://mcp-document-server-xxxx-uc.a.run.app/sse # Scales to zero when no requests — costs ~$0 when idle
Terminal — VPS deployment (DigitalOcean / Hetzner)
# ── 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; # } # }
Container started: mcp-server Health check passing at http://127.0.0.1:8000/health nginx configured for mcp.yourdomain.com with Let's Encrypt TLS # SSE endpoint: https://mcp.yourdomain.com/sse
VPS nginx + SSE: Disable Buffering nginx buffers responses by default. SSE requires that events flush to the client immediately. The 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

ItemWhy RequiredHow
TLS (HTTPS)Bearer tokens over HTTP are visible to network eavesdroppersFly.io / Cloud Run: automatic. VPS: nginx + Let's Encrypt
Secrets managementJWT_SECRET in env files = credential leak riskFly secrets, GCP Secret Manager, or HashiCorp Vault
Rate limitingUnauthenticated token endpoint can be brute-forcednginx rate_limit, Cloud Armor, or Fly.io rate limiting
Health check endpointPlatform needs liveness signal to avoid routing to crashed containerGET /health returning 200 (already implemented)
Request loggingDebugging tool call failures requires a log trailstructlog (Python) or pino (Node.js) with JSON output
You now have everything you need. The lab puts it all together: convert MCP01's server, containerize it, and connect Claude Code.

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.

1

Convert mcp.run() to HTTP transport

Open your document_server.py from MCP01. The only required change is the last two lines:

Python — document_server.py (change only this)
# 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")),
    )
Checkpoint 1

Run python document_server.py and curl the health endpoint: curl http://localhost:8000/health. You should see {"status":"ok"}.

2

Write the Dockerfile

Create Dockerfile in the same directory as your server:

Dockerfile — minimal, working
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:

requirements.txt
mcp>=1.0
uvicorn>=0.29
Checkpoint 2

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.

3

docker build && docker run

Terminal
docker run -d --name mcp-lab -p 8000:8000 mcp-lab && curl http://localhost:8000/health
a1b2c3d4e5f6... {"status":"ok","server":"document-server","tools":3}
Checkpoint 3

The health check returns 200 and the container is running. Verify: docker ps --filter name=mcp-lab — STATUS should show healthy after about 30 seconds.

4

Update .claude/settings.json

In your project root, edit (or create) .claude/settings.json:

JSON — .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"}.

Checkpoint 4

The settings.json file is saved. No restart needed for Claude Code — it reads settings on session start.

5

Test from Claude Code

Terminal — start a new Claude Code session
claude
> /mcp Connected MCP servers: document-server (http://localhost:8000/sse) ✓ connected Tools: search_documents, calculate_similarity, save_note > Search my documents for anything about transport Using tool: search_documents (document-server) {"query": "transport", "limit": 5} Result: [{"id":"transport-docs","preview":"stdio is local; HTTP/SSE is for shared...","score":3}] I found one document about transport: the "transport-docs" entry explains that stdio is for local use while HTTP/SSE enables shared, remote servers.
Checkpoint 5 — Lab Complete

Claude 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.

Cleanup When you're done: 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?

A
The HTTP 202 response body from the POST to /messages
B
A WebSocket message pushed after the POST completes
C
A data: event pushed on the persistent SSE channel opened with GET /sse
D
A separate GET request the client polls after the POST returns

2. What single argument to mcp.run() converts a FastMCP server from stdio to HTTP transport?

A
transport="websocket"
B
transport="streamable-http"
C
mode="http"
D
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?

A
gzip on; — response compression is interfering
B
proxy_buffering off; combined with proxy_cache off; — nginx buffers responses by default, queuing SSE events instead of flushing them immediately
C
keepalive_timeout 0; — connection timeout is too short
D
ssl_protocols TLSv1.3; — TLS version mismatch causes buffering

4. 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?

A
Set Access-Control-Allow-Origin: * — wildcard allows all origins
B
Move the MCP server to the same domain as the web app
C
Set Access-Control-Allow-Origin: https://app.example.com and Access-Control-Allow-Credentials: true on the MCP server's responses
D
CORS cannot be configured for SSE endpoints — only POST endpoints support CORS

5. 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?

A
stdio — each engineer spawns their own subprocess copy of the server, so 10 engineers = 10 process instances all running in parallel
B
HTTP + SSE — one deployed server process accepts multiple concurrent SSE connections; engineers connect by URL so no local installation is needed
C
Both stdio and HTTP work equally well for multi-user scenarios
D
HTTP + SSE only works for browser clients, not Claude Code

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?

A
The port was not published with -p in the docker run command
B
127.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.
C
FastMCP does not support binding to specific IP addresses
D
Docker requires the server to listen on port 80 by default