fix: Prevent FastMCP 3.4.3 host guard from 421-ing the plugin endpoint - #241
Merged
Conversation
FastMCP 3.4.3 added HostOriginGuardMiddleware, enabled by default, which only accepts loopback Host headers (127.0.0.1/localhost/::1) plus the ASGI scope server host and returns "421 Misdirected Request" for anything else. In plugin mode the MCP app is embedded in the Airflow webserver and reached only through the platform ingress (TLS for the deployment hostname, auth, bearer token), so the incoming Host is a real hostname and every request to /mcp/v1/ was rejected with 421. Standalone/local mode is unaffected -- loopback is exactly the case the guard is designed to protect. Delegate the Host/Origin check to the ingress in plugin mode: - plugin.py passes host_origin_protection=False to both mcp.http_app() calls (AF3 FastAPI + AF2 Flask). A signature probe keeps it a no-op on older FastMCP that lacks the kwarg. - ASTRO_MCP_ALLOWED_HOSTS (comma-separated) opts back into an explicit host allowlist instead of disabling the guard. - Pin fastmcp>=3.4.3,<4 -- the kwargs only exist from 3.4.3, and the upper bound stops the next default-behaviour change from silently breaking installs. Standalone mode (__main__.py, mcp.run) keeps FastMCP's defaults.
Addresses code review. The fastmcp>=3.4.3,<4 pin guarantees host_origin_protection/allowed_hosts always exist, so the inspect.signature probe, its except path, and the empty-dict fallback were dead code. Pass the kwargs directly and drop `import inspect`. Also hoist the `_host_guard_kwargs` import to module top per the repo import convention (inline imports only when genuinely needed), and drop the now-unreachable "older FastMCP" test.
Member
|
Does Astro Cloud inject the hostname into the webserver/apiserver env vars? Wonder if we could keep the option but pass it the real hostname? Seems like something that would be injected but not sure. |
Per review feedback: rather than disabling HostOriginGuardMiddleware, keep it on and scope it to the Deployment's own hostname, which Astro injects as AIRFLOW__WEBSERVER__BASE_URL (an env var, so available at plugin import time -- unlike Airflow's conf, which is populated later). This preserves DNS-rebinding protection instead of turning it off. - Derive allowed_hosts from the base URL host by default. - ASTRO_MCP_ALLOWED_HOSTS overrides it (custom domains / extra hosts). - Fall back to host_origin_protection=False only when no hostname is derivable (non-Astro embeddings). Standalone mode keeps FastMCP defaults.
Contributor
Author
Updated |
kaxil
enabled auto-merge (squash)
July 8, 2026 14:43
schnie
approved these changes
Jul 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FastMCP 3.4.3 added
HostOriginGuardMiddleware, enabled by default: it only accepts loopbackHostheaders (127.0.0.1/localhost/::1) plus the ASGIscope["server"]host, and returns421 Misdirected Requestfor anything else.In plugin mode the MCP app is embedded in the Airflow webserver and reached over the Deployment's own hostname, so with an unpinned install that resolves fastmcp 3.4.3, every
/mcp/v1/request is rejected with421. (The AF2 Flask bridge also builds the ASGI scope with noserverkey, so the scope-server fallback doesn't apply.)This keeps the guard enabled but scoped to the Deployment's hostname, restoring
/mcp/v1/to a normal200MCP handshake. Standalone/local mode is untouched.What changed
plugin.py: bothmcp.http_app()calls (AF3 FastAPI + AF2 Flask) keepHostOriginGuardMiddlewareenabled, scoped to the Deployment hostname derived fromAIRFLOW__WEBSERVER__BASE_URL(an env var, so present at import — Airflow'sconfisn't yet). Falls back tohost_origin_protection=Falseonly when no hostname is derivable (non-Astro embeddings).ASTRO_MCP_ALLOWED_HOSTS(comma-separated) overrides the derived allowlist — custom domains or extra hosts.fastmcp>=3.4.3,<4: the kwargs only exist from 3.4.3, and the upper bound stops the next default-behaviour change from silently breaking installs.Design rationale
base_urlis Astro's canonical hostname source — both the deployment ingress route (Host preserved, no authority rewrite) and the auth-proxy'schangeOriginconverge on that same per-deployment hostname — so scoping to it matches theHostthat actually arrives, and it auto-tracks any future hostname change rather than going stale.ASTRO_MCP_ALLOWED_HOSTS; the disable-fallback covers non-Astro embeddings where no hostname is known.__main__.py,mcp.run) keeps FastMCP's loopback default, since a locally-bound server is what DNS-rebinding protection is for.Interim workaround (no plugin upgrade needed)
Pin
fastmcp<3.4.3in the Astro project'srequirements.txtand redeploy.