Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/adr/0003-teardown-marker-accepted-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions docs/adr/0009-fastmcp-teardown-wraps-user-lifespan.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions docs/integrations/fastmcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
31 changes: 15 additions & 16 deletions lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
32 changes: 31 additions & 1 deletion tests/test_fastmcp_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading