Files
kb/openspec/changes/archive/2026-04-04-mcp-allowed-hosts/design.md
T
2026-04-04 22:50:19 +01:00

3.9 KiB

Context

The MCP Python SDK includes DNS rebinding protection via TransportSecuritySettings in mcp.server.transport_security. When enabled, it validates the Host header against an allowlist and returns 421 for unrecognised hosts.

FastMCP auto-enables this protection when host is 127.0.0.1, localhost, or ::1, with a default allowlist of those three values (wildcard port). The kb MCP server does not pass a host to FastMCP() and does not pass transport_security, so the behaviour depends on the SDK's defaults — which have changed between versions and will likely keep changing.

Currently the server calls mcp.streamable_http_app() to get a Starlette sub-app and wraps it in its own Starlette app with BearerAuthMiddleware. The transport_security settings flow through FastMCP()SettingsStreamableHTTPSessionManager, so they must be set at FastMCP construction time.

When a remote client connects (e.g. Host: 192.168.1.50:3000), the SDK rejects the request with 421 before our auth middleware even runs.

Goals / Non-Goals

Goals:

  • Allow operators to configure additional allowed hosts via environment variable so remote clients can connect.
  • Support both IP addresses and FQDNs, with or without port.
  • Preserve DNS rebinding protection (keep it enabled, just widen the allowlist).
  • Maintain backward compatibility — unset variable means localhost-only, same as today.

Non-Goals:

  • Disabling DNS rebinding protection entirely.
  • Configuring allowed origins separately from allowed hosts (derive origins automatically from hosts).
  • TLS termination or HTTPS — that belongs to a reverse proxy in front of the MCP container.

Decisions

1. Environment variable format

KB_MCP_ALLOWED_HOSTS is a comma-separated list of hosts. Each entry is an IP address or FQDN without port and without scheme.

Examples: 192.168.1.50, kb.example.com, 192.168.1.50,kb.example.com,10.0.0.1

Rationale: Comma-separated is the simplest format that doesn't require quoting in Docker Compose YAML or shell environments. Ports are omitted because the wildcard-port pattern (host:*) covers all ports — operators shouldn't need to know the internal port.

Alternative considered: JSON array — rejected, awkward in env vars and Compose files.

2. Merge with localhost defaults

The parsed hosts are merged with the hardcoded localhost set (127.0.0.1, localhost, [::1]). Localhost is always allowed regardless of the env var value.

Rationale: Removing localhost would break local development and health checks. There's no reason to ever disallow it.

3. Auto-derive allowed origins from allowed hosts

For each allowed host, generate http://<host>:* as an allowed origin. No separate env var for origins.

Rationale: The MCP server doesn't serve HTTPS (TLS is terminated by a reverse proxy), so http:// is always correct at the container level. If HTTPS origins are needed in future, a separate env var can be added then.

4. Pass TransportSecuritySettings explicitly to FastMCP

Always construct a TransportSecuritySettings with enable_dns_rebinding_protection=True and the merged allowlist, and pass it as transport_security= to FastMCP(). This makes the behaviour explicit rather than depending on SDK defaults.

Rationale: The SDK's auto-detection logic depends on the host parameter which we don't set, and the defaults may change between SDK versions. Being explicit removes the ambiguity.

Risks / Trade-offs

  • Operator must know their Host header value — If a reverse proxy rewrites the Host header, the operator needs to allowlist the rewritten value, not the original. → Mitigation: document this in Compose file comments.
  • No HTTPS origin support — If a client sends Origin: https://..., it will be rejected. → Mitigation: acceptable for now; the MCP server sits behind a proxy that terminates TLS. Can add KB_MCP_ALLOWED_ORIGINS later if needed.