Skip to content

Upgrade to fastmcp 4 and use its native host/origin guard - #18

Merged
algis-dumbris merged 4 commits into
mainfrom
chore/fastmcp-4
Sep 10, 2026
Merged

algis-dumbris merged 4 commits into
mainfrom
chore/fastmcp-4

Conversation

@algis-dumbris

@algis-dumbris algis-dumbris commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

The HTTP transport served anything that reached the listener — no Host or Origin check. The MCP spec requires validating Origin, otherwise a page you visit can reach a loopback listener by DNS rebinding and drive the tools with the operator's API key.

fastmcp 4.0.2 ships a guard for this, so we upgrade and turn it on instead of writing our own.

Two things worth knowing:

  • The guard is off by default, so upgrading alone fixes nothing. It's enabled explicitly.
  • It's set to strict, not "auto". "auto" only checks when bound to loopback, which skips the check on exactly the exposed setup that needs it.

GCORE_ALLOWED_HOSTS / GCORE_ALLOWED_ORIGINS configure the allow-lists (glob patterns). Loopback is always allowed; no browser origin is allowed by default. Requests without an Origin header pass, so normal MCP clients are unaffected. stdio is untouched.

Crossing two majors needed two mechanical fixes: fastmcp.tools.tool → fastmcp.tools, and model_dump(by_alias=True) in the schema tests since the keys went snake_case.

The Streamable HTTP transport served any request that reached the listener
without checking the Host or Origin header. The MCP Streamable HTTP
specification requires servers to validate Origin, so that a page the
operator visits cannot reach a loopback-bound listener through DNS
rebinding and drive the server's tools with the operator's API key.

fastmcp 4.0.2 ships HostOriginGuardMiddleware for exactly this, but it is
off by default (`http_host_origin_protection` defaults to False), so this
upgrades the pin and enables the guard explicitly. It is set to True rather
than "auto": "auto" only validates when the listener is bound to loopback,
which would silently drop the check on the exposed deployment that needs it
most. With True, reaching the server under any other name requires an
explicit GCORE_ALLOWED_HOSTS entry.

- Host must match GCORE_ALLOWED_HOSTS; loopback names are always accepted
  and entries are glob patterns. Unlisted hosts get 421.
- Origin, when present, must match GCORE_ALLOWED_ORIGINS, empty by default.
  Unlisted origins get 403. Requests with no Origin header are unaffected,
  so non-browser MCP clients keep working unchanged.

The stdio transport is untouched. Client authentication is still not
implemented; the docstring no longer implies it is coming and the README
says so plainly.

Upgrading fastmcp across two majors needed two mechanical fixes in existing
code: `fastmcp.tools.tool` is now `fastmcp.tools`, and
`Tool.to_mcp_tool().model_dump()` returns snake_case keys, so the schema
tests ask for `by_alias=True` to keep asserting on the wire format.
@algis-dumbris
algis-dumbris changed the base branch from security/http-origin-validation to main September 4, 2026 09:51
@algis-dumbris
algis-dumbris requested review from deferred and a lite review from Copilot September 7, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a few correctness/maintainability issues to address (docstring contract mismatch and a likely dev-install dependency gap for httpx).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Upgrades the server to FastMCP v4 and enables FastMCP’s native Host/Origin guard for the HTTP transport, aligning the implementation with the MCP Streamable HTTP security requirements and preventing DNS rebinding-style access from browser contexts.

Changes:

  • Bumped fastmcp to >=4.0.2,<5 and updated imports / schema test expectations for v4 (Tool import path and model_dump(by_alias=True)).
  • Enabled FastMCP host/origin protection for HTTP mode in main(), sourcing allow-lists from new GCORE_ALLOWED_HOSTS / GCORE_ALLOWED_ORIGINS env vars.
  • Added end-to-end HTTP transport security tests and documented the new configuration in the README.
File summaries
File Description
gcore_mcp_server/server.py Enables FastMCP host/origin guard for HTTP transport and wires allow-lists from env.
gcore_mcp_server/config/settings.py Adds env var constants and helper to parse comma-separated allow-lists.
tests/test_http_security.py New end-to-end tests for Host/Origin guard behavior + allow-list parsing.
tests/test_schema.py Updates FastMCP Tool import and schema dumps to use aliases under v4.
tests/test_e2e_smoke.py Updates FastMCP Tool import and schema dumps to use aliases under v4.
README.md Documents Host/Origin validation behavior and the new allow-list env vars.
pyproject.toml Upgrades FastMCP dependency and adds httpx to the dev dependency group.
Review details
  • Files reviewed: 7/8 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pyproject.toml
Comment thread gcore_mcp_server/config/settings.py Outdated
Comment thread tests/test_http_security.py
- Declare httpx in the legacy `[project.optional-dependencies].dev` extra
  as well as the dependency group, so `pip install -e .[dev]` also gets it;
  the tests use starlette's TestClient, which needs it.
- State get_allow_list's full contract: unset, empty and whitespace-only
  all return None.
- The test helper builds the app with the same guard configuration main()
  passes to mcp.run(); say that rather than claiming it does what main()
  does.

@pedrodeoliveira pedrodeoliveira left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCORE_TRANSPORT=sse is still accepted by _TRANSPORT_MAP, but FastMCP 4 does not install HostOriginGuardMiddleware for its SSE app. main() passes these three guard arguments for every non-stdio transport, yet FastMCP.http_app() only consumes them in the http/streamable-http branch; the sse branch calls create_sse_app() without them. Consequently an SSE deployment remains reachable with arbitrary Host/Origin headers, despite the module and README claiming HTTP requests are protected. The added tests instantiate the default streamable-HTTP app and therefore do not catch this path. Please either remove/disable the sse transport, or explicitly wrap its ASGI app with equivalent Host/Origin protection and add an SSE regression test.

Separately, the required FOSSA check currently fails with a new policy violation from this dependency refresh: uv.lock moves idna from 3.10 to 3.19, which FOSSA classifies as Unicode-3.0. docutils was already failing on main, but the idna finding is introduced by this PR and needs a dependency resolution or policy decision before merge.

FastMCP only installs HostOriginGuardMiddleware in its streamable-HTTP
app. Its http_app() forwards host_origin_protection and the allow-lists in
the http/streamable-http branch only; the sse branch calls create_sse_app(),
which takes no such arguments. So GCORE_TRANSPORT=sse would have started a
listener with none of the Host/Origin validation the module and README
claim for HTTP.

SSE is removed from the accepted transports and rejected explicitly rather
than left to the unknown-value fallback: falling back to stdio would turn a
misconfiguration into a process that silently reads stdin instead of the
network listener the operator asked for. The process now logs the reason
and exits 2.

Transport resolution moves to config.settings as resolve_transport(), a
pure function, so it can be tested without importing server.py's
module-level client construction.

Tests: the refusal itself, the alias table, the unknown-value fallback, and
one test pinning the upstream fact that the SSE app ignores the guard
arguments -- so if FastMCP ever guards SSE, that test fails and the refusal
can be revisited.
Findings from an external review of the previous commits.

A bare `mcp.run()` on the stdio branch let FastMCP choose the transport
from its own settings: `FASTMCP_TRANSPORT=http` or `=sse` in the
environment (or a .env file) started a listener with no Host/Origin guard,
bypassing both the strict-mode guard and the SSE refusal whenever
GCORE_TRANSPORT was unset. The stdio branch now passes
`transport="stdio"`; a test drives main() with FASTMCP_TRANSPORT=sse and
asserts the explicit transport, and fails on the previous code.

The allow-lists are now always passed as explicit lists, never None. None
made FastMCP substitute FASTMCP_HTTP_ALLOWED_HOSTS / _ORIGINS, a second
configuration channel that could widen the policy behind the documented
GCORE_* variables. Empty lists keep the built-in defaults and close that
channel.

Rejecting an unsupported transport moved from import time to main(): the
module is imported by tests and tooling (`fastmcp inspect`, the e2e smoke
test), and a SystemExit during import terminated those too.

Docs corrected against FastMCP's actual policy: the bound address is
always accepted (so exposure does not strictly require an allow-list
entry), same-origin and loopback origins are always accepted, ports are
ignored in host patterns, and IPv6 literal origins cannot be allow-listed
because the brackets are read as glob syntax. Only the console entry point
applies the policy; `fastmcp run ...:mcp` does not. The SSE sentinel test
now asserts the transport's own 404 rather than "not 421/403", so
unrelated failures cannot keep it green.
@algis-dumbris

algis-dumbris commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, both points were right.

SSE — dropped rather than wrapped (873c69f). GCORE_TRANSPORT=sse now exits 2 with the reason instead of falling back to stdio. Tests cover the refusal plus one sentinel that pins the upstream fact that the SSE app ignores the guard args, so if FastMCP ever guards SSE that test fails and we can reconsider.

idna — can't be pinned around: fastmcp 4 → fastmcp-slim → httpx2 → idna>=3.18 is a hard chain (idna<3.11 makes resolution fail outright). It's the Unicode licence on the bundled data tables, the library is still BSD-3; needs a LEGAL whitelist entry. Noted in the description.

@algis-dumbris
algis-dumbris merged commit 3ceb53b into main Sep 10, 2026
4 of 7 checks passed
@algis-dumbris
algis-dumbris deleted the chore/fastmcp-4 branch September 10, 2026 11:15
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.

3 participants