Skip to content

fix: Prevent FastMCP 3.4.3 host guard from 421-ing the plugin endpoint - #241

Merged
kaxil merged 3 commits into
mainfrom
fix-mcp-host-origin-guard-421
Jul 8, 2026
Merged

fix: Prevent FastMCP 3.4.3 host guard from 421-ing the plugin endpoint#241
kaxil merged 3 commits into
mainfrom
fix-mcp-host-origin-guard-421

Conversation

@kaxil

@kaxil kaxil commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

FastMCP 3.4.3 added HostOriginGuardMiddleware, enabled by default: it 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 over the Deployment's own hostname, so with an unpinned install that resolves fastmcp 3.4.3, every /mcp/v1/ request is rejected with 421. (The AF2 Flask bridge also builds the ASGI scope with no server key, 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 normal 200 MCP handshake. Standalone/local mode is untouched.

What changed

  • plugin.py: both mcp.http_app() calls (AF3 FastAPI + AF2 Flask) keep HostOriginGuardMiddleware enabled, scoped to the Deployment hostname derived from AIRFLOW__WEBSERVER__BASE_URL (an env var, so present at import — Airflow's conf isn't yet). Falls back to host_origin_protection=False only when no hostname is derivable (non-Astro embeddings).
  • ASTRO_MCP_ALLOWED_HOSTS (comma-separated) overrides the derived allowlist — custom domains or extra hosts.
  • 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.

Design rationale

  • Scope the guard vs. disable it: keeping the guard on preserves DNS-rebinding protection. base_url is Astro's canonical hostname source — both the deployment ingress route (Host preserved, no authority rewrite) and the auth-proxy's changeOrigin converge on that same per-deployment hostname — so scoping to it matches the Host that actually arrives, and it auto-tracks any future hostname change rather than going stale.
  • Custom domains / other Host values: handled by ASTRO_MCP_ALLOWED_HOSTS; the disable-fallback covers non-Astro embeddings where no hostname is known.
  • Scope: only the embedded/plugin path changes. Standalone (__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.3 in the Astro project's requirements.txt and redeploy.

kaxil added 2 commits July 8, 2026 19:00
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.
@kaxil
kaxil requested a review from a team July 8, 2026 13:42
@schnie

schnie commented Jul 8, 2026

Copy link
Copy Markdown
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.
@kaxil

kaxil commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

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.

Updated

@kaxil
kaxil enabled auto-merge (squash) July 8, 2026 14:43
@kaxil
kaxil merged commit e83f757 into main Jul 8, 2026
10 checks passed
@kaxil
kaxil deleted the fix-mcp-host-origin-guard-421 branch July 8, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants