diff --git a/docs/adr/0003-teardown-marker-accepted-limits.md b/docs/adr/0003-teardown-marker-accepted-limits.md index bffb959..bf8b3ca 100644 --- a/docs/adr/0003-teardown-marker-accepted-limits.md +++ b/docs/adr/0003-teardown-marker-accepted-limits.md @@ -17,3 +17,6 @@ Two consequences are accepted: `Litestar.from_config()`. Two configs sharing one `AppConfig` collide, but that pattern is already broken anyway, since instrument bootstrap mutates the shared config's `cors_config`, `route_handlers` and `openapi_config`. + +[ADR 0009](0009-fastmcp-teardown-wraps-user-lifespan.md) supersedes the FastMCP provider-list +example above. The marker still guards against attaching a second bootstrapper. diff --git a/docs/adr/0009-fastmcp-teardown-wraps-user-lifespan.md b/docs/adr/0009-fastmcp-teardown-wraps-user-lifespan.md new file mode 100644 index 0000000..1aa5214 --- /dev/null +++ b/docs/adr/0009-fastmcp-teardown-wraps-user-lifespan.md @@ -0,0 +1,16 @@ +# FastMCP teardown wraps the user lifespan + +Supersedes [ADR 0001](0001-fastmcp-teardown-via-provider-lifespan.md). + +FastMCP 4 enters the user lifespan before entering provider lifespans. The provider approach +therefore shuts down tracing before user shutdown code runs. The bootstrapper wraps the +constructor-supplied lifespan on the existing server and forwards its result to FastMCP. +Teardown runs after that context exits, including when startup or shutdown raises. + +FastMCP 4.0.0 through 4.1.6 releases tested here read the private `_lifespan` callable from their lifecycle manager. +This dependency keeps the supplied server instance and applies to transports that share that +manager. The regression exercises the manager through a FastMCP client. + +## Breaking changes + +Lite-bootstrap teardown runs after FastMCP user lifespan shutdown. diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index cc94be8..0e7ebb6 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -112,8 +112,7 @@ The metrics path, the health-check path (unless `opentelemetry_generate_health_c `opentelemetry_excluded_urls` and `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` produce no spans. An application already instrumented by `StarletteInstrumentor` is left as is, so no request is traced twice. -Teardown is wired through FastMCP's provider lifecycle: `bootstrapper.teardown()` -runs automatically when the FastMCP server's ASGI lifespan shuts down (i.e. when -the application that serves `application.http_app()` shuts down). +FastMCP runs `bootstrapper.teardown()` automatically after the user lifespan exits. +Tracing remains active while the user's shutdown code runs. Read more about available configuration options [here](../introduction/configuration.md). diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 00350af..225fd46 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -26,7 +26,6 @@ from fastmcp import FastMCP from fastmcp.server.http import StarletteWithLifespan from fastmcp.server.middleware import Middleware, MiddlewareContext - from fastmcp.server.providers import Provider from starlette import status from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -127,20 +126,6 @@ def build_fastmcp_route_details_from_scope( if import_checker.is_fastmcp_installed: - class _TeardownProvider(Provider): - # FastMCP exposes no on_shutdown-style API; Provider.lifespan is the only public - # post-construction hook whose async-cm runs during ASGI startup/shutdown. - def __init__(self, teardown: typing.Callable[[], None]) -> None: - super().__init__() - self._teardown = teardown - - @contextlib.asynccontextmanager - async def lifespan(self) -> AsyncGenerator[None]: - try: - yield - finally: - self._teardown() - class FastMcpPrometheusMiddleware(Middleware): def __init__( self, @@ -400,7 +385,21 @@ def is_ready(self) -> bool: def __init__(self, bootstrap_config: FastMcpConfig) -> None: super().__init__(bootstrap_config) application = self.bootstrap_config.application - self._attach_teardown_once(application, lambda: application.add_provider(_TeardownProvider(self.teardown))) + self._attach_teardown_once(application, lambda: self._wrap_lifespan(application)) + + def _wrap_lifespan(self, application: "FastMCP[typing.Any]") -> None: + # FastMCP unwinds provider lifespans before its user lifespan. + user_lifespan = application._lifespan # noqa: SLF001 + + @contextlib.asynccontextmanager + async def lifespan(server: "FastMCP[typing.Any]") -> AsyncGenerator[typing.Any]: + try: + async with user_lifespan(server) as result: + yield result + finally: + self.teardown() + + application._lifespan = lifespan # noqa: SLF001 def _prepare_application(self) -> "FastMCP[typing.Any]": return self.bootstrap_config.application diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index f80d681..26d2a7c 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -2,13 +2,14 @@ import typing import uuid import warnings -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import MagicMock import prometheus_client import pytest from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError +from fastmcp.server.context import Context from fastmcp.server.http import StarletteWithLifespan from fastmcp.server.middleware import MiddlewareContext from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware @@ -87,6 +88,35 @@ async def send(message: dict[str, typing.Any]) -> None: return outbox +async def test_fastmcp_exports_user_shutdown_span_and_preserves_lifespan_result() -> None: + @contextlib.asynccontextmanager + async def lifespan(_app: FastMCP) -> AsyncGenerator[dict[str, str]]: + yield {"service": "ready"} + with get_tracer_provider().get_tracer(__name__).start_as_current_span("user-shutdown"): + pass + + app = FastMCP(lifespan=lifespan) + + @app.tool + def read_state(ctx: Context) -> str: + return ctx.lifespan_context["service"] + + bootstrapper = FastMcpBootstrapper( + bootstrap_config=FastMcpConfig(application=app, opentelemetry_log_traces=True), + ) + application = bootstrapper.bootstrap() + provider = get_tracer_provider() + assert isinstance(provider, SDKTracerProvider) + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + async with Client(application) as client: + assert (await client.call_tool("read_state")).data == "ready" + + assert "user-shutdown" in [span.name for span in exporter.get_finished_spans()] + assert not bootstrapper.is_bootstrapped + + async def test_fastmcp_teardown_runs_via_asgi_lifespan() -> None: bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) application = bootstrapper.bootstrap()