mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 22:14:18 +02:00
Drive the FastMCP lifespan through the SDK session manager (#4446)
This commit is contained in:
parent
ac78e6f693
commit
6621024ce4
9 changed files with 245 additions and 16 deletions
|
|
@ -285,6 +285,14 @@ FastMCP retains hardening that is not yet upstream and does not remove it during
|
|||
|
||||
### Retained OAuth / DCR hardening — Absorbed
|
||||
|
||||
FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface. When HTTP convergence lands in v4, FastMCP would additionally *inherit* the SDK's session-owner credential enforcement — a security gain it lacks today (see [Feature Program](/development/v4-notes/feature-program)).
|
||||
FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface.
|
||||
|
||||
*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`.
|
||||
|
||||
### Session-owner credential enforcement — Absorbed (security gain)
|
||||
|
||||
A streamable-HTTP session is now bound to the credential that created it. Once the session manager owns the server lifecycle (its `run()` drives FastMCP's lifespan through `_lifespan_proxy`), the SDK's `_session_owners` map is populated on session creation and checked on every subsequent request: a request that presents a *different* credential for an existing `Mcp-Session-Id` is answered with 404, exactly as if the session did not exist. This closes a gap where a leaked session id was usable by any bearer — before this, any valid token could drive someone else's session. Identity is the `(client_id, issuer, subject)` triple the token verifier supplies; components it does not populate degrade out of the comparison.
|
||||
|
||||
The enforcement is the SDK's, but it only activates on FastMCP's stateful HTTP path because FastMCP lets the manager enter the lifespan (see HTTP → Lifespan reconciliation above). No configuration is required.
|
||||
|
||||
*Verify:* `tests/server/http/test_session_owner_enforcement.py` (create with token A → reuse with token B → 404; reuse with token A → 200); SDK `streamable_http_manager.py` `_session_owners`.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ These control how the server listens when running with an HTTP transport.
|
|||
| `FASTMCP_HTTP_HOST_ORIGIN_PROTECTION` | `bool` | `true` | Validate `Host` and browser `Origin` headers for Streamable HTTP requests. |
|
||||
| `FASTMCP_HTTP_ALLOWED_HOSTS` | `list[str] \| null` | `null` | Additional trusted hostnames for Streamable HTTP requests. Use a JSON array, such as `["mcp.example.com"]`. |
|
||||
| `FASTMCP_HTTP_ALLOWED_ORIGINS` | `list[str] \| null` | `null` | Browser origins trusted by the Streamable HTTP request guard. Configure CORS separately for cross-origin browser reads. Use a JSON array, such as `["https://app.example.com"]`. |
|
||||
| `FASTMCP_HTTP_SESSION_IDLE_TIMEOUT` | `float \| null` | `null` | Seconds a Streamable HTTP session may remain idle before it is terminated. The deadline resets on every request. When `null`, sessions never expire from inactivity. Not supported in stateless mode. |
|
||||
| `FASTMCP_DEBUG` | `bool` | `false` | Enable debug mode. |
|
||||
|
||||
## Error Handling
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ Token validation must address several security requirements: signature verificat
|
|||
|
||||
The challenge in MCP environments is that clients need to obtain valid tokens before making requests, but the MCP protocol doesn't provide built-in discovery mechanisms for token endpoints. Clients must obtain tokens through separate channels or prior configuration.
|
||||
|
||||
On the streamable-HTTP transport, each session is additionally bound to the credential that created it: a request that presents a different credential for an existing `Mcp-Session-Id` is rejected with a 404, exactly as if the session did not exist. A leaked session id is therefore useless without the original credential. Session identity is the `(client_id, issuer, subject)` triple your verifier populates.
|
||||
|
||||
|
||||
## TokenVerifier Class
|
||||
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ def create_streamable_http_app(
|
|||
host_origin_protection: bool = True,
|
||||
allowed_hosts: Sequence[str] | None = None,
|
||||
allowed_origins: Sequence[str] | None = None,
|
||||
session_idle_timeout: float | None = None,
|
||||
) -> StarletteWithLifespan:
|
||||
"""Return an instance of the StreamableHTTP server app.
|
||||
|
||||
|
|
@ -518,6 +519,10 @@ def create_streamable_http_app(
|
|||
allowed_origins: Additional browser origins trusted by the request guard.
|
||||
Configure CORS separately when browser JavaScript must read
|
||||
cross-origin responses.
|
||||
session_idle_timeout: Maximum time in seconds a session may remain idle
|
||||
before it is terminated. The deadline is pushed forward on every
|
||||
request. When None, sessions never expire from inactivity. Not
|
||||
supported in stateless mode.
|
||||
|
||||
Returns:
|
||||
A Starlette application with StreamableHTTP support
|
||||
|
|
@ -599,6 +604,7 @@ def create_streamable_http_app(
|
|||
retry_interval=retry_interval,
|
||||
json_response=json_response,
|
||||
stateless=stateless_http,
|
||||
session_idle_timeout=session_idle_timeout,
|
||||
# FastMCP owns DNS-rebinding protection via HostOriginGuardMiddleware,
|
||||
# which is more expressive and already the documented surface. Always
|
||||
# disable the SDK's own protection so the two layers don't
|
||||
|
|
@ -607,10 +613,11 @@ def create_streamable_http_app(
|
|||
enable_dns_rebinding_protection=False
|
||||
),
|
||||
)
|
||||
async with (
|
||||
server._lifespan_manager(),
|
||||
streamable_http_app.session_manager.run(),
|
||||
):
|
||||
# The session manager's `run()` enters `server._mcp_server.lifespan`
|
||||
# (our `_lifespan_proxy`), which now drives `server._lifespan_manager()`.
|
||||
# Entering it here too would double-stack the same lifespan, so we let
|
||||
# the manager own the single entry.
|
||||
async with streamable_http_app.session_manager.run():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ class TransportMixin:
|
|||
host_origin_protection: bool | None = None,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
allowed_origins: list[str] | None = None,
|
||||
session_idle_timeout: float | None = None,
|
||||
) -> StarletteWithLifespan:
|
||||
"""Create a Starlette app using the specified HTTP transport.
|
||||
|
||||
|
|
@ -369,6 +370,9 @@ class TransportMixin:
|
|||
allowed_origins: Additional browser origins trusted by the request guard.
|
||||
Configure CORS separately when browser JavaScript must read
|
||||
cross-origin responses.
|
||||
session_idle_timeout: Maximum time in seconds a streamable-HTTP
|
||||
session may remain idle before it is terminated. When None,
|
||||
falls back to the ``http_session_idle_timeout`` setting.
|
||||
|
||||
Returns:
|
||||
A Starlette application configured with the specified transport
|
||||
|
|
@ -410,6 +414,11 @@ class TransportMixin:
|
|||
if allowed_origins is not None
|
||||
else fastmcp.settings.http_allowed_origins
|
||||
),
|
||||
session_idle_timeout=(
|
||||
session_idle_timeout
|
||||
if session_idle_timeout is not None
|
||||
else fastmcp.settings.http_session_idle_timeout
|
||||
),
|
||||
)
|
||||
elif transport == "sse":
|
||||
return create_sse_app(
|
||||
|
|
|
|||
|
|
@ -289,17 +289,16 @@ def _lifespan_proxy(
|
|||
async def wrap(
|
||||
low_level_server: LowLevelServer[LifespanResultT],
|
||||
) -> AsyncIterator[LifespanResultT]:
|
||||
if fastmcp_server._lifespan is default_lifespan:
|
||||
yield {} # ty:ignore[invalid-yield]
|
||||
return
|
||||
|
||||
if not fastmcp_server._lifespan_result_set:
|
||||
raise RuntimeError(
|
||||
"FastMCP server has a lifespan defined but no lifespan result is set, which means the server's context manager was not entered. "
|
||||
" Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/PrefectHQ/fastmcp/issues."
|
||||
)
|
||||
|
||||
yield fastmcp_server._lifespan_result # ty:ignore[invalid-yield]
|
||||
# Drive the FastMCP lifespan rather than merely reading it back. The
|
||||
# SDK enters this proxy exactly once per manager/server run (via
|
||||
# ``StreamableHTTPSessionManager.run`` → ``app.lifespan(app)`` or
|
||||
# ``Server.run`` → ``self.lifespan(self)``) and reuses the yielded
|
||||
# state for every session. ``_lifespan_manager`` is ref-counted, so
|
||||
# when an outer caller (``run_http_async``/``run_stdio_async``) has
|
||||
# already entered it, this nested entry reuses the existing result
|
||||
# instead of re-running setup.
|
||||
async with fastmcp_server._lifespan_manager():
|
||||
yield fastmcp_server._lifespan_result # ty:ignore[invalid-yield]
|
||||
|
||||
return wrap
|
||||
|
||||
|
|
|
|||
|
|
@ -339,6 +339,21 @@ class Settings(BaseSettings):
|
|||
http_host_origin_protection: bool = True
|
||||
http_allowed_hosts: list[str] | None = None
|
||||
http_allowed_origins: list[str] | None = None
|
||||
http_session_idle_timeout: Annotated[
|
||||
float | None,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Maximum time in seconds a streamable-HTTP session may remain
|
||||
idle before it is terminated. A session's deadline is pushed
|
||||
forward on every request. When None (default), sessions never
|
||||
expire from inactivity. Not supported in stateless HTTP mode.
|
||||
Must be a positive number of seconds when set.
|
||||
"""
|
||||
),
|
||||
gt=0,
|
||||
),
|
||||
] = None
|
||||
|
||||
mounted_components_raise_on_load_error: Annotated[
|
||||
bool,
|
||||
|
|
|
|||
79
tests/server/http/test_session_idle_timeout.py
Normal file
79
tests/server/http/test_session_idle_timeout.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Tests for the streamable-HTTP ``session_idle_timeout`` setting.
|
||||
|
||||
An idle session is terminated after ``session_idle_timeout`` seconds of
|
||||
inactivity. The deadline is reset on every request. This is the SDK's
|
||||
behavior, surfaced through FastMCP's ``http_session_idle_timeout`` setting.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from fastmcp.server import FastMCP
|
||||
from fastmcp.server.http import (
|
||||
StarletteWithLifespan,
|
||||
StreamableHTTPASGIApp,
|
||||
create_streamable_http_app,
|
||||
)
|
||||
|
||||
INITIALIZE_REQUEST = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "client", "version": "0.1"},
|
||||
},
|
||||
}
|
||||
|
||||
MCP_HEADERS = {"accept": "application/json, text/event-stream"}
|
||||
|
||||
|
||||
def _find_session_manager(app: StarletteWithLifespan):
|
||||
for route in app.router.routes:
|
||||
endpoint = getattr(route, "endpoint", None)
|
||||
if isinstance(endpoint, StreamableHTTPASGIApp):
|
||||
return endpoint.session_manager
|
||||
return None
|
||||
|
||||
|
||||
def test_idle_session_is_terminated_after_timeout():
|
||||
server = FastMCP(name="IdleTimeoutServer")
|
||||
app = create_streamable_http_app(
|
||||
server=server,
|
||||
streamable_http_path="/mcp",
|
||||
session_idle_timeout=0.2,
|
||||
)
|
||||
|
||||
with TestClient(app, base_url="http://127.0.0.1") as client:
|
||||
response = client.post("/mcp", headers=MCP_HEADERS, json=INITIALIZE_REQUEST)
|
||||
assert response.status_code == 200
|
||||
session_id = response.headers.get("mcp-session-id")
|
||||
assert session_id is not None
|
||||
|
||||
sm = _find_session_manager(app)
|
||||
assert sm is not None
|
||||
assert session_id in sm._server_instances
|
||||
|
||||
# Wait past the idle deadline; the SDK's idle cancel scope fires and
|
||||
# removes the session from the active instances. Poll to stay fast.
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
if session_id not in sm._server_instances:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
assert session_id not in sm._server_instances
|
||||
|
||||
# The now-expired session id is rejected with 404.
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
headers={
|
||||
**MCP_HEADERS,
|
||||
"mcp-session-id": session_id,
|
||||
"mcp-protocol-version": "2024-11-05",
|
||||
},
|
||||
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
109
tests/server/http/test_session_owner_enforcement.py
Normal file
109
tests/server/http/test_session_owner_enforcement.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""End-to-end tests for session-owner credential enforcement.
|
||||
|
||||
A streamable-HTTP session is bound to the credential that created it. A request
|
||||
that presents a different credential for the same ``Mcp-Session-Id`` is rejected
|
||||
with 404, exactly as if the session did not exist. This closes a gap where a
|
||||
leaked session id was usable by any bearer.
|
||||
|
||||
Enforcement lives in the SDK's ``StreamableHTTPSessionManager`` (the
|
||||
``_session_owners`` map). It is active on FastMCP's stateful HTTP path because
|
||||
the session manager owns the server lifecycle (its ``run()`` drives our
|
||||
lifespan through ``_lifespan_proxy``).
|
||||
"""
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from fastmcp.server import FastMCP
|
||||
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
||||
from fastmcp.server.http import StarletteWithLifespan, create_streamable_http_app
|
||||
|
||||
INITIALIZE_REQUEST = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "client", "version": "0.1"},
|
||||
},
|
||||
}
|
||||
|
||||
TOOLS_LIST_REQUEST = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
TOKEN_A = "token-a"
|
||||
TOKEN_B = "token-b"
|
||||
|
||||
MCP_HEADERS = {"accept": "application/json, text/event-stream"}
|
||||
|
||||
|
||||
def _make_app() -> StarletteWithLifespan:
|
||||
verifier = StaticTokenVerifier(
|
||||
tokens={
|
||||
TOKEN_A: {"client_id": "client-a", "scopes": []},
|
||||
TOKEN_B: {"client_id": "client-b", "scopes": []},
|
||||
}
|
||||
)
|
||||
server = FastMCP(name="OwnerEnforcementServer", auth=verifier)
|
||||
return create_streamable_http_app(
|
||||
server=server,
|
||||
streamable_http_path="/mcp",
|
||||
auth=verifier,
|
||||
)
|
||||
|
||||
|
||||
def _initialize(client: TestClient, token: str) -> str:
|
||||
"""Create a session with the given token and return its session id."""
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
headers={**MCP_HEADERS, "authorization": f"Bearer {token}"},
|
||||
json=INITIALIZE_REQUEST,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
session_id = response.headers.get("mcp-session-id")
|
||||
assert session_id is not None
|
||||
return session_id
|
||||
|
||||
|
||||
def test_session_reuse_with_creating_credential_succeeds():
|
||||
app = _make_app()
|
||||
with TestClient(app, base_url="http://127.0.0.1") as client:
|
||||
session_id = _initialize(client, TOKEN_A)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
headers={
|
||||
**MCP_HEADERS,
|
||||
"authorization": f"Bearer {TOKEN_A}",
|
||||
"mcp-session-id": session_id,
|
||||
"mcp-protocol-version": "2024-11-05",
|
||||
},
|
||||
json=TOOLS_LIST_REQUEST,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_session_reuse_with_different_credential_returns_404():
|
||||
"""A session created with credential A must not be usable with credential B."""
|
||||
app = _make_app()
|
||||
with TestClient(app, base_url="http://127.0.0.1") as client:
|
||||
session_id = _initialize(client, TOKEN_A)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
headers={
|
||||
**MCP_HEADERS,
|
||||
"authorization": f"Bearer {TOKEN_B}",
|
||||
"mcp-session-id": session_id,
|
||||
"mcp-protocol-version": "2024-11-05",
|
||||
},
|
||||
json=TOOLS_LIST_REQUEST,
|
||||
)
|
||||
|
||||
# Responds exactly as if the session did not exist.
|
||||
assert response.status_code == 404
|
||||
Loading…
Add table
Add a link
Reference in a new issue