DEV Community

Jangwook Kim
Jangwook Kim

Posted on Originally published at effloow.com

FastMCP Streamable HTTP in Production: Auth, DNS Rebinding, and Docker Gotchas

Why We Brought This Tool Into Our Lab

The Model Context Protocol, or MCP, lets AI applications call tools and access data. Our local MCP prototypes were easy to run but difficult to deploy securely.

Auth vs authorization

FastMCP can verify bearer tokens and reject hostile Host/Origin values, but production OAuth still needs a separate authorization service, exact HTTPS callback URLs, and a deliberate session strategy across proxy and server instances.

Standard input and output, or STDIO, lets a local application communicate directly with a tool process. Process ownership and operating-system permissions help control access. Streamable HTTP carries MCP messages over web requests, making the tools accessible over a network. That introduces new security risks. Clients present bearer tokens as proof of access, and reverse proxies forward requests to the tool server. Browsers can also send requests. Sessions preserve context across requests, while public callback URLs receive clients returning from an authorization service.

FastMCP attracted us because it handles much of the communication code. We could define Python functions with declared input types, register them as tools, and make them available through MCP. JSON-RPC is a format for sending structured requests to remote functions and receiving their results. FastMCP handled the initial message exchange, advertised supported features, tracked sessions, and converted results into response messages.

That convenience does not make the resulting service production-ready.

FastMCP still requires a Python web-service deployment. The Asynchronous Server Gateway Interface, or ASGI, connects Python applications to web servers. We must connect authentication to a real identity system that verifies who is making a request. We must also implement authorization, which determines what that requester may do. nginx must preserve the request headers that carry protocol and security information. Transport Layer Security, or TLS, encrypts network connections. Where nginx handles that encryption, its configuration must match the public URLs clients use. Clients must retain the Streamable HTTP session throughout setup. The Host header names the requested server, while the Origin header identifies the website making a browser request. FastMCP must check both and reject unexpected domains.

We specifically tested the Host and Origin validation introduced through the merged FastMCP DNS rebinding protection work. DNS rebinding tricks a browser into contacting a local or internal service by changing where a domain name points. It is a practical risk for remote MCP servers. A malicious site can try to make a browser send requests with a hostile origin to a service on localhost or an internal network. If the server accepts arbitrary Host values and browser origins, a malicious site can reach a developer’s locally exposed MCP endpoint by using the browser’s access to the network.

Our target architecture was intentionally ordinary:

  1. A FastMCP application exposed /mcp over Streamable HTTP.
  2. OAuth lets apps request access without a user's password. Our mock service checked access tokens using the RFC 7662 introspection standard. Introspection means asking an authorization service whether a token is active and what access it grants.
  3. nginx terminating TLS and forwarding requests to FastMCP.
  4. Docker Compose providing an isolated, reproducible network.
  5. An explicit allowlist defined which Host and Origin values the server would accept.
  6. Bearer-token verification on every protected MCP request.

We used a verifier rather than pretending that token validation was a complete OAuth implementation. A token verifier answers, “Is this presented access token valid, unexpired, correctly scoped, and intended for this resource?” It does not provide an authorization endpoint, login screen, consent flow, token endpoint, client registration, or callback processing.

That distinction became one of our most important findings. FastMCP can protect the server that provides tools, but production OAuth still needs a separate service to sign users in and issue access tokens. Clients may also need protected-resource metadata: published information about the service and how to obtain authorized access. They may require an exact HTTPS callback URL for returning from authorization. Token verification alone does not provide these functions.

Set up FastMCP and test an authenticated tool call

We started with a clean Python virtual environment and verified the supported package name rather than installing similarly named MCP packages:

python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install fastmcp
python -c "import fastmcp; print(fastmcp.__version__)"
Enter fullscreen mode Exit fullscreen mode

After the first successful run, we recorded exact dependency versions in a lock file so we could reproduce the setup. We fixed the fastmcp version in the container image used for repeat testing.

Our FastMCP server used a custom TokenVerifier to check access tokens. It asked the mock service whether each token was active and unexpired. It also required the tools:invoke scope, a permission allowing the token holder to call tools.

# server.py
import os
import time

import httpx
from fastmcp import FastMCP
from fastmcp.server.auth import AccessToken, TokenVerifier
from mcp.server.transport_security import TransportSecuritySettings


class IntrospectionVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        async with httpx.AsyncClient(timeout=2.0) as client:
            response = await client.post(
                os.environ["INTROSPECTION_URL"],
                data={"token": token},
                auth=(
                    os.environ["INTROSPECTION_CLIENT_ID"],
                    os.environ["INTROSPECTION_CLIENT_SECRET"],
                ),
            )

        if response.status_code != 200:
            return None

        payload = response.json()
        scopes = payload.get("scope", "").split()
        expires_at = int(payload.get("exp", 0))

        if not payload.get("active"):
            return None
        if expires_at <= int(time.time()):
            return None
        if "tools:invoke" not in scopes:
            return None

        return AccessToken(
            token=token,
            client_id=payload.get("client_id", "unknown"),
            scopes=scopes,
            expires_at=expires_at,
        )


mcp = FastMCP(
    "effloow-lab",
    auth=IntrospectionVerifier(),
)


@mcp.tool
def deployment_status(service: str) -> dict:
    """Return a deterministic lab deployment result."""
    return {
        "service": service,
        "status": "ready",
        "transport": "streamable-http",
    }


if __name__ == "__main__":
    mcp.run(
        transport="http",
        host="0.0.0.0",
        port=8000,
        path="/mcp",
        transport_security=TransportSecuritySettings(
            enable_dns_rebinding_protection=True,
            allowed_hosts=[
                "mcp.local",
                "mcp.local:*",
                "localhost",
                "localhost:*",
            ],
            allowed_origins=[
                "https://mcp.local",
                "https://mcp.local:*",
                "https://localhost",
                "https://localhost:*",
            ],
        ),
    )
Enter fullscreen mode Exit fullscreen mode

FastMCP examples and releases have used different names for the Streamable HTTP setting. In our tested interface, transport="http" selected Streamable HTTP. Older examples using transport="streamable-http" did not work across every version we examined. We relied on the installed command-line interface's help and the parameter lists of imported functions for our fixed package version.

Our mock introspection endpoint kept the authentication test deterministic:

# introspection.py
import time

from fastapi import FastAPI, Form

app = FastAPI()


@app.post("/introspect")
async def introspect(token: str = Form(...)):
    if token != "dev-token":
        return {"active": False}

    return {
        "active": True,
        "client_id": "lab-client",
        "scope": "tools:invoke",
        "exp": int(time.time()) + 3600,
    }
Enter fullscreen mode Exit fullscreen mode

We installed httpx, fastapi, uvicorn, and python-multipart alongside FastMCP. Our application image was deliberately simple:

# Dockerfile
FROM python:3.12-slim

WORKDIR /app

RUN pip install --no-cache-dir \
    fastmcp \
    httpx \
    fastapi \
    uvicorn \
    python-multipart

COPY server.py introspection.py ./

CMD ["python", "server.py"]
Enter fullscreen mode Exit fullscreen mode

nginx handled encrypted client connections and forwarded the bearer token and original hostname to FastMCP. It also recorded whether the client used HTTP or HTTPS. We disabled response buffering so nginx could forward Streamable HTTP responses without waiting to collect them:

# nginx.conf
events {}

http {
    upstream fastmcp_app {
        server app:8000;
    }

    server {
        listen 8443 ssl;
        server_name mcp.local localhost;

        ssl_certificate     /etc/nginx/certs/lab.crt;
        ssl_certificate_key /etc/nginx/certs/lab.key;
        ssl_protocols TLSv1.2 TLSv1.3;

        location /mcp {
            proxy_pass http://fastmcp_app;
            proxy_http_version 1.1;

            proxy_set_header Host $host;
            proxy_set_header Authorization $http_authorization;
            proxy_set_header Origin $http_origin;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Proto https;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            proxy_buffering off;
            proxy_cache off;
            proxy_read_timeout 300s;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We then connected all three processes with Docker Compose:

# compose.yaml
services:
  app:
    build: .
    environment:
      INTROSPECTION_URL: http://introspection:9000/introspect
      INTROSPECTION_CLIENT_ID: fastmcp-resource
      INTROSPECTION_CLIENT_SECRET: local-only-secret
    depends_on:
      - introspection

  introspection:
    build: .
    command: uvicorn introspection:app --host 0.0.0.0 --port 9000

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "8443:8443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app
Enter fullscreen mode Exit fullscreen mode

For local TLS, we generated a disposable certificate and started the stack:

mkdir -p certs

openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout certs/lab.key \
  -out certs/lab.crt \
  -days 2 \
  -subj "/CN=localhost"

docker compose up --build
Enter fullscreen mode Exit fullscreen mode

We initialized the MCP session through nginx, saved the response headers, and extracted the session identifier:

curl -kisS \
  -D /tmp/mcp-headers \
  -o /tmp/mcp-init.json \
  -H 'Host: localhost' \
  -H 'Authorization: Bearer dev-token' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  --data '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": {"name": "effloow-curl", "version": "1.0"}
    }
  }' \
  https://localhost:8443/mcp

cat /tmp/mcp-headers
cat /tmp/mcp-init.json
Enter fullscreen mode Exit fullscreen mode

Our successful response had this shape:

HTTP/1.1 200 OK
content-type: application/json
mcp-session-id: 3db976d10f714d68a0c09ec586785f35
mcp-protocol-version: 2025-06-18

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"effloow-lab","version":"2.x"}}}
Enter fullscreen mode Exit fullscreen mode

We sent notifications/initialized before invoking the tool. We also reused the exact Mcp-Session-Id response header on subsequent requests:

SESSION_ID=$(awk 'BEGIN{IGNORECASE=1} /^mcp-session-id:/ {
  gsub("\r", "", $2); print $2
}' /tmp/mcp-headers)

curl -kisS \
  -H "Mcp-Session-Id: ${SESSION_ID}" \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -H 'Authorization: Bearer dev-token' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  --data '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "deployment_status",
      "arguments": {"service": "remote-mcp"}
    }
  }' \
  https://localhost:8443/mcp
Enter fullscreen mode Exit fullscreen mode

The returned tool result was:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"service\":\"remote-mcp\",\"status\":\"ready\",\"transport\":\"streamable-http\"}"
      }
    ],
    "isError": false
  }
}
Enter fullscreen mode Exit fullscreen mode

Deployment failures and their fixes

The first failure looked like an authentication bug. nginx returned a response from the protected endpoint, but FastMCP behaved as though no token had arrived. The cause was the proxy configuration: our initial template did not explicitly forward Authorization.

Many nginx configurations forward ordinary headers automatically, but relying on inherited behavior was too fragile for us. The header can disappear or change at several steps. Settings inherited from another configuration, a component that routes incoming traffic, or a separate authentication check can alter it. We fixed it with:

proxy_set_header Authorization $http_authorization;
Enter fullscreen mode Exit fullscreen mode

The second failure was a DNS rebinding rejection after we enabled the merged Host and Origin checks. nginx originally sent Host: app:8000 upstream. That internal Docker hostname was not in our public allowlist, so valid requests failed. We changed nginx to preserve the externally validated host:

proxy_set_header Host $host;
Enter fullscreen mode Exit fullscreen mode

We did not “fix” this by adding every internal and external hostname to the allowlist. Broad wildcard acceptance would have defeated the protection we were trying to deploy. We allowed only the public MCP hostname and explicit local development names.

We checked that FastMCP rejected requests containing hostile values:

curl -kisS \
  -H 'Host: attacker.example' \
  -H 'Origin: https://attacker.example' \
  -H 'Authorization: Bearer dev-token' \
  https://localhost:8443/mcp
Enter fullscreen mode Exit fullscreen mode

FastMCP rejected the request before running a tool. We added this check to our basic deployment tests because configuration changes can unintentionally allow requests for additional hostnames.

Session handling caused the most confusing protocol errors. An initialize request can succeed while the next call fails because the client discarded Mcp-Session-Id, changed backend instances, omitted the protocol-version header, or skipped the initialized notification. We saw responses equivalent to “session not found” when we copied the JSON body but not the response header.

Running more server instances makes this problem more serious. If one FastMCP process stores a client's session, another process does not automatically have that information. We had three options. Sticky routing sends a client's requests to the same process. Shared sessions let multiple processes access the same session information, where our FastMCP version supports this. Stateless HTTP avoids retaining session information between requests, where clients and tools support that behavior. Adding server instances before choosing an approach produced intermittent failures.

Our OAuth callback tests exposed a separate class of proxy problems. When X-Forwarded-Proto was missing, generated metadata and redirect URLs could use http:// even though the client connected over HTTPS. OAuth providers generally require the return address to match the registered callback address exactly. A difference in HTTP versus HTTPS, hostname, port, path, or final slash is enough to interrupt authorization.

We therefore treated these values as one deployment contract:

  • External resource URL: https://mcp.example.com/mcp
  • OAuth metadata URLs: public HTTPS URLs
  • Registered callback URI: the exact client callback
  • Forwarded scheme: https
  • Forwarded host: mcp.example.com
  • FastMCP allowed host: mcp.example.com
  • FastMCP allowed origin: only approved browser origins

Token checks also made availability depend on the identity service. Our first implementation asked that service to validate every request. When the mock service stopped running, all protected MCP operations failed. This was the intended fail-closed behavior: deny access when verification is unavailable. A production deployment needs limits on how long checks can wait, reusable network connections, backup identity-service capacity, and strict limits on reusing previous verification results.

We would not cache a positive introspection response beyond the token’s expiry. We would also account for revocation requirements before choosing any cache duration. A signed JSON Web Token, or JWT, carries claims that a server can verify using a cryptographic signature. Local verification can avoid contacting the introspection service. We must still check who issued the token, its intended service, expiry, signing method, changes to signing keys, and required permissions.

Finally, we had to separate authentication from authorization. A valid access token did not automatically give the user or service it represented permission to call every tool. Our compact lab verifier enforced one permission requirement across all tools. Production tools need rules for each tool, separation between customers or organizations, checks on inputs, and audit records showing who did what. Destructive tools deserve stronger controls than read-only status tools.

Scale, Latency & Cost vs. Alternatives

What this lab could not verify

This setup did not establish representative production performance. We used the test scripts to check protocol behavior, authentication, request forwarding, and rejection of unsafe requests. We did not publish simulated measurements of requests handled per second or response times.

Where latency accumulates

The architecture still showed where requests spend time. Checking a token with an identity service adds a network request unless we can safely reuse an earlier result. Handling encrypted connections also takes processing time at the public-facing proxy. Stored session information limits how we distribute requests across servers. Tool execution usually takes the most time when tools call databases, hosted software services, models, or internal systems.

Our comparison came down to control versus operational burden:

Option What we would own Strongest fit Main production risk Relative operating effort
FastMCP plus nginx OAuth integration, TLS policy, session routing, observability, upgrades Python teams needing rapid custom tool development Version drift and incomplete security configuration Medium
Official MCP Python software development kit for building MCP applications More code for protocol handling and application behavior Teams needing low-level transport control More code to review and maintain High
Managed remote MCP platform Tool code and provider configuration Small teams prioritizing deployment speed Platform dependency, pricing, and reduced network control Low to medium
Custom FastAPI or ASGI gateway Nearly the entire protocol and security layer Specialized environments with existing gateway infrastructure Reinvented MCP behavior and interoperability bugs Very high
STDIO-only MCP Local process packaging and permissions Desktop or single-host integrations No practical remote multi-client service boundary Low locally, unsuitable remotely

FastMCP was cheaper for us than building JSON-RPC and MCP session behavior from scratch. Running it still required work. We needed secured container images, safely supplied credentials, renewed TLS certificates, and identity-provider configuration. We also needed logs, checks that services were running, fixed dependency versions, and repeated tests to catch security failures after changes.

For a practical break-even calculation, we use our own labor rate and provider quote rather than treating a hypothetical number as market pricing. The formula is:

Break-even months =
    initial self-hosting engineering cost
    /
    (monthly managed price - monthly self-hosting infrastructure and operations)
Enter fullscreen mode Exit fullscreen mode

For illustration, assume an engineer costs the company $150 per hour, including employment costs beyond wages. Two engineer-days then produce an initial cost of $2,400. If a managed option costs $300 per month and the equivalent self-hosted runtime plus routine operations costs $40 per month, the nominal break-even point is:

$2,400 / ($300 - $40) = 9.23 months
Enter fullscreen mode Exit fullscreen mode

That estimate changes quickly. An incident, OAuth migration, compliance review, or repeated FastMCP upgrade can erase the apparent saving. Self-hosting can cost substantially less for teams that already run nginx, manage containers across servers, centralize OAuth, and monitor service behavior.

For internal tool servers with stable traffic, our preference is FastMCP behind an existing gateway. For customer-facing MCP shared by multiple customers, we would compare managed deployment with the full cost of running the service ourselves. That includes access rules, retaining session data, abuse controls, records that let us trace actions, and on-call responsibility—not just the container bill.

Teams comparing adjacent infrastructure can also check our tools collection. Our AI infrastructure services review identity checks, request-routing gateways, and agent permissions across the deployment, not just the MCP process.

Our Final Verdict: When to Deploy, When to Skip

FastMCP worked well in our lab as an application framework for remote MCP tools. It did not replace the infrastructure and security work surrounding those tools.

Deployment became straightforward once we treated Streamable HTTP as a network service with its own security and session requirements. Simply making a local tool available over a network was not enough. An unsafe setup listens on every network interface using 0.0.0.0, accepts every hostname, and checks only whether a bearer token exists. Adding nginx does not make that setup secure.

Deploy this if:

  • We already operate Python services and understand ASGI deployment patterns.
  • We need to move typed Python tools from local MCP prototypes to remote clients.
  • We can pin FastMCP and its MCP SDK dependency as one tested release unit.
  • We have an OAuth authorization server or identity provider already.
  • We can distinguish token verification from the complete authorization flow.
  • We can enforce issuer, audience, expiry, scope, and tenant checks.
  • We can maintain explicit Host and Origin allowlists.
  • We can preserve Authorization, Host, Origin, and forwarded scheme headers through the proxy.
  • We have chosen a deliberate stateful, sticky-session, shared-session, or stateless scaling model.
  • We can test unauthorized, expired-token, hostile-host, hostile-origin, and missing-session paths during deployment.

Hold off or avoid it if:

  • We expect FastMCP itself to become our identity provider.
  • We cannot guarantee stable public HTTPS URLs for OAuth metadata and callbacks.
  • We need multi-region session continuity without designing a session strategy.
  • Our tools need complex rules for access to individual resources, and we have not yet defined those rules.
  • We plan to expose administrative tools directly to the internet without a gateway or rate controls.
  • We cannot pin versions and rerun basic checks of protocol behavior during upgrades.
  • A local STDIO integration already solves the actual business requirement.
  • Our security plan is limited to hiding the endpoint URL.

Before release, we would automate four requests: a valid initialize sequence, a valid tool call, an invalid bearer token, and a hostile Host/Origin request. We would then repeat the valid sequence through every intermediary in the deployed system. That includes load balancers that distribute requests and content delivery networks that relay traffic through geographically distributed servers. It also includes ingress components that route incoming traffic and service meshes that manage communication between services. A successful request sent directly to the container does not prove that requests through these intermediaries preserve MCP behavior.

We also recommend reviewing the concrete deployment patterns at DeployMCP and the Python container and proxy walkthrough in Kevin Tan’s deployment guide. We used those guides to cross-check our implementation and retained our own TLS, header, token, and session tests as the acceptance criteria.

Our verdict is positive but conditional: FastMCP helps teams build MCP tool applications faster. We would deploy it for authenticated internal tool services and controlled remote integrations. We would not deploy it as an unreviewed public endpoint, and we would never mistake successful tool registration for production readiness.

If it is unclear who can access the tools or how requests pass through the reverse proxy, contact our infrastructure team before attaching high-impact tools to the endpoint. The expensive failure is not a broken demo. It is an agent that passes identity checks but gains access to a tool it should not have permission to use.

Top comments (0)