Repository navigation
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
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
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 FalseWith 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
anythingoah commentedon Sep 3, 2026
Hey, ran into this yourself in a live setup, or caught it while testing/CI? Curious whether it was a silent 421 that took a while to trace, or something that failed loud and fast.
UgaTheDev commentedon Sep 5, 2026
Confirmed on current
main: both the exact-match check and the:*wildcard loop inTransportSecurityMiddleware._validate_hostcompare theHostheader case-sensitively, and_validate_originhas the same shape. I also checked the PR that introduced the check (#3164); nothing in it or its review chose case-sensitive matching deliberately, so this reads as an oversight rather than a design decision.I have a fix ready that lowercases both sides for the comparison only (log messages keep the original header value), with a parametrized regression test covering upper, lower and mixed-case
HostandOriginvalues against both exact and wildcard-port allowlist entries. The existing 26 transport-security tests still pass.Could a maintainer assign this to me (or label it
help wanted) so I can open the PR?amasen02 commentedon Sep 5, 2026
Confirming this behavior on current
main. Per RFC 9110 §4.2.3 ("The scheme and host are case-insensitive and normally provided in lowercase...") and RFC 6454 §4, hostnames must be compared case-insensitively.Currently, both the exact match and the
:*wildcard-port loop inTransportSecurityMiddleware._validate_host()and_validate_origin()perform case-sensitive string comparisons. In environments where hostnames are configured in uppercase (notably Windows default%COMPUTERNAME%or capitalized domain names), any request from standard WHATWG fetch clients (browsers, Nodefetch,undici,mcp-remote), which automatically lowercase host headers, is rejected with421 Misdirected Request.A minimal fix is to normalize both the incoming header value and the configured allowlist entries to lowercase during comparison (preserving the original header string for logging and error reporting), covering both exact matches and wildcard-port stripping.
Regression testing confirms that uppercase allowlist configurations correctly accept lowercase incoming requests, and all 31 transport security tests pass (along with the full 28-job CI matrix on #3455). Ready to assist with this whenever the team is ready.
chrono-glitch commentedon Sep 5, 2026
I'll open a PR for this. Plan: lowercase both sides of the comparison in
_validate_hostand_validate_origin(exact match + the:*wildcard loop), keeping the original value in the log line, plus tests for an uppercaseallowed_hostsentry vs a lowercaseHostheader and the reverse. Targetingmain(v2); happy to also do the v1 branch. (Using an AI coding assistant; I review and test everything.)zsxh1990 commentedon Sep 7, 2026
I'd like to work on this fix.
The issue is clear: comparison uses exact string match, but RFC 9110 requires case-insensitive host comparison. A fetch client sending gets 421'd even when is in the allowlist.
Approach: Normalize both the allowlist entries and the incoming Host/Origin headers to lowercase before comparison. This is a one-line fix in the host validation logic.
Happy to submit a PR if this direction looks right.
zsxh1990 commentedon Oct 13, 2026
Withdrawing my claim on this from 2026-09-07 ("I'd like to work on this fix").
Reason: the four PRs opened for this issue (#3440, #3453, #3455, #3456) were all auto-closed by
require-linked-issue— not on their code, but because the author was not assigned to #3437. A fifth PR from me would meet the same fate. My earlier comment was also the kind of comment that policy says does not help, so this issue has effectively been parked on an unfulfilled claim.This is not a withdrawal of the diagnosis. For whoever is assigned, the shape of the fix is narrow. Both
_validate_hostand_validate_origininsrc/mcp/server/transport_security.pycompare case-sensitively at four sites:RFC 9110 §4.2.3 makes the host component case-insensitive, and the wildcard branches inherit the same problem through
startswith.Origincarries no path component, so lowercasing the whole value is safe there as well. Nothing else in the middleware depends on the original casing — the warning logs should keep the raw value they do today. Verified againstmainatc9356a2a: still exact match, still no normalisation.I am not asking to be assigned — that is a maintainer call. Commenting only to make the record honest: the work is not claimed by me any more.