Skip to content

TransportSecuritySettings.allowed_hosts is compared case-sensitively (NOT RFC 9110 compliant), so an uppercase host entry (the Windows default) rejects every fetch-based client with 421 #3437

Description

@OsakaTX

Initial Checks

Release line

2.x (current stable)

Description

Summary

TransportSecurityMiddleware._validate_host() compares the incoming Host header
against allowed_hosts case-sensitively. Host names are case-insensitive per RFC 9110,
and every WHATWG-URL based client (undici, fetch, browsers, and therefore
mcp-remote) lowercases the URL host before sending it.

On Windows this is the default path into the bug, because %COMPUTERNAME% is always
uppercase. Deriving allowed_hosts from the machine name, which is the obvious thing
to do, yields an entry like MYHOST:*. Clients then send host: myhost:8000, the
comparison fails, and the server returns 421 to every request while being configured
exactly as intended.

Expected behavior

allowed_hosts matching should be case-insensitive. RFC 9110 is explicit:

The scheme and host are case-insensitive and normally provided in lowercase; all
other components are compared in a case-sensitive manner.

Actual behavior

Observed against a live Streamable HTTP server configured with allowed_hosts=["MYHOST:*"],
varying only the Host header and holding everything else byte-identical:

Host: sent Result
MYHOST:8000 (uppercase, matches config) 200 OK
myhost:8000 (what any fetch client sends) 421 Misdirected Request
10.0.0.5:8000 (by IP) 421 Misdirected Request

The uppercase form is the only one accepted, and no client can be made to send it.
Node:

new URL('http://MYHOST:8000/mcp').host   // => "myhost:8000"

The WHATWG URL standard requires that lowercasing, so this is not configurable
client-side. Supplying an explicit Host header does not help either: undici treats
Host as a forbidden header and overwrites it from the URL.

Root cause

src/mcp/server/transport_security.py (lines 50-70 as of v2.1.1):

def _validate_host(self, host: str | None) -> bool:
    """Validate the Host header against allowed values."""
    if not host:
        logger.warning("Missing Host header in request")
        return False

    # Check exact match first
    if host in self.settings.allowed_hosts:          # case-sensitive
        return True

    # Check wildcard port patterns
    for allowed in self.settings.allowed_hosts:
        if allowed.endswith(":*"):
            base_host = allowed[:-2]
            if host.startswith(base_host + ":"):     # case-sensitive prefix
                return True

    logger.warning(f"Invalid Host header: {host}")
    return False

With allowed_hosts = ["MYHOST:*"], base_host becomes "MYHOST" and the test is
"myhost:8000".startswith("MYHOST:"), which is False. There is no .lower()
anywhere in this function; the file's only .lower() call is the content-type check
on line 96.

Suggested fix

Normalize both sides:

def _validate_host(self, host: str | None) -> bool:
    if not host:
        logger.warning("Missing Host header in request")
        return False

    host = host.lower()
    allowed_hosts = [a.lower() for a in self.settings.allowed_hosts]

    if host in allowed_hosts:
        return True

    for allowed in allowed_hosts:
        if allowed.endswith(":*") and host.startswith(allowed[:-2] + ":"):
            return True

    logger.warning(f"Invalid Host header: {host}")
    return False

_validate_origin() has the same structure and the same issue for its scheme and host
portions.

This direction of change is safe. The current behavior is stricter than the
configuration expresses, so it fails closed: it rejects hosts it was told to allow and
can never accept one it was not. Lowercasing aligns it with the documented intent
rather than loosening it.

Secondary issue: the 421 never reaches the client

Worth fixing alongside, because it is what makes this expensive to diagnose.

The middleware rejects on headers alone and the response completes without the request
body being drained. A client that streams its request body has its write cut short and
then reports an error about its own request instead of surfacing the 421. With undici
this appears as:

TypeError: fetch failed
  [cause]: RequestContentLengthMismatchError: Request body length does not match content-length header
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'

Neither the 421 nor the word "host" appears anywhere in the client output, and the
request body was in fact well formed: every body captured on the wire matched its
declared Content-Length exactly. That error points squarely at the client, which is
where the debugging time goes.

Draining the request body before returning the 421 would let clients report the real
status and make this class of misconfiguration self-diagnosing.

Impact

Any Windows Streamable HTTP deployment that derives allowed_hosts from the machine
name is unreachable by every fetch-based client, and the failure surfaces as a
client-side error that never mentions hosts. Linux deployments mostly escape it because
hostnames there are already lowercase, which is likely why this has not been reported
before.

Example Code

"""Minimal reproduction: case-sensitive allowed_hosts matching.

On Windows, %COMPUTERNAME% is uppercase, so configuring allowed_hosts from the
machine name produces an uppercase entry. Every WHATWG-URL client lowercases the
host before sending it, so the request is rejected with 421.
"""

from mcp.server.transport_security import (
    TransportSecurityMiddleware,
    TransportSecuritySettings,
)

# What you get on Windows from os.environ["COMPUTERNAME"]
settings = TransportSecuritySettings(allowed_hosts=["MYHOST:*"])
mw = TransportSecurityMiddleware(settings)

print(mw._validate_host("MYHOST:8000"))  # True  - but no client sends this
print(mw._validate_host("myhost:8000"))  # False - what every client actually sends

# Why no client sends the uppercase form (Node / undici / browsers):
#   new URL('http://MYHOST:8000/mcp').host  ->  "myhost:8000"
# The WHATWG URL standard mandates the lowercasing, so it cannot be worked
# around from the client side.

Python & MCP Python SDK

Python 3.13.2 (CPython, Windows x86_64)
mcp 2.0.0
Client: mcp-remote 0.8.3 on Node 22.18.0
Transport: Streamable HTTP, stateless_http=True, uvicorn

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions