From 652b7ddb4593e9d5cb1439e5ae1e576069debc50 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 22:05:00 +0300 Subject: [PATCH 01/19] feat: detect fastmcp via import_checker --- lite_bootstrap/import_checker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lite_bootstrap/import_checker.py b/lite_bootstrap/import_checker.py index 531c5c0..238bab4 100644 --- a/lite_bootstrap/import_checker.py +++ b/lite_bootstrap/import_checker.py @@ -16,3 +16,4 @@ is_opentelemetry_installed and is_litestar_installed and find_spec("opentelemetry.instrumentation.asgi") is not None ) is_pyroscope_installed = find_spec("pyroscope") is not None +is_fastmcp_installed = find_spec("fastmcp") is not None From 74b09182e4de642c374b136c183949ffe42fff3c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 22:06:42 +0300 Subject: [PATCH 02/19] feat: add fastmcp and fastmcp-metrics extras --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b9431dc..d2bf73b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ keywords = [ "litestar", "faststream", "structlog", + "fastmcp", ] classifiers = [ "Programming Language :: Python :: 3.10", @@ -115,6 +116,13 @@ faststream-all = [ "lite-bootstrap[faststream-sentry,faststream-otl,faststream-logging,faststream-metrics,pyroscope]", "lite-bootstrap[sentry,otl,logging,faststream,faststream-metrics,pyroscope]" ] +fastmcp = [ + "fastmcp", +] +fastmcp-metrics = [ + "lite-bootstrap[fastmcp]", + "prometheus-client>=0.20", +] [dependency-groups] dev = [ From 97a741b8d8b0aa92b887ed29984c637d45bc6a22 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 22:19:41 +0300 Subject: [PATCH 03/19] feat: scaffold FastMcpBootstrapper and FastMcpConfig --- lite_bootstrap/__init__.py | 3 ++ .../bootstrappers/fastmcp_bootstrapper.py | 38 +++++++++++++++++++ tests/test_fastmcp_bootstrap.py | 23 +++++++++++ 3 files changed, 64 insertions(+) create mode 100644 lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py create mode 100644 tests/test_fastmcp_bootstrap.py diff --git a/lite_bootstrap/__init__.py b/lite_bootstrap/__init__.py index 171185a..b3a71b6 100644 --- a/lite_bootstrap/__init__.py +++ b/lite_bootstrap/__init__.py @@ -1,4 +1,5 @@ from lite_bootstrap.bootstrappers.fastapi_bootstrapper import FastAPIBootstrapper, FastAPIConfig +from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpBootstrapper, FastMcpConfig from lite_bootstrap.bootstrappers.faststream_bootstrapper import FastStreamBootstrapper, FastStreamConfig from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig, FreeConfig from lite_bootstrap.bootstrappers.litestar_bootstrapper import LitestarBootstrapper, LitestarConfig @@ -19,6 +20,8 @@ "ConfigurationError", "FastAPIBootstrapper", "FastAPIConfig", + "FastMcpBootstrapper", + "FastMcpConfig", "FastStreamBootstrapper", "FastStreamConfig", "FreeBootstrapper", diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py new file mode 100644 index 0000000..b11d1bc --- /dev/null +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -0,0 +1,38 @@ +import dataclasses +import typing + +from lite_bootstrap import import_checker +from lite_bootstrap.bootstrappers.base import BaseBootstrapper +from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig +from lite_bootstrap.instruments.logging_instrument import LoggingConfig +from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig +from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig +from lite_bootstrap.instruments.sentry_instrument import SentryConfig + + +if import_checker.is_fastmcp_installed: + from fastmcp import FastMCP + + +def _make_fastmcp() -> "FastMCP[typing.Any]": + return FastMCP() + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig): + application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp) + logging_turn_off_middleware: bool = False + + +class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): + __slots__ = "bootstrap_config", "instruments" + + instruments_types: typing.ClassVar = [] + bootstrap_config: FastMcpConfig + not_ready_message = "fastmcp is not installed" + + def is_ready(self) -> bool: + return import_checker.is_fastmcp_installed + + 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 new file mode 100644 index 0000000..8e1ddb0 --- /dev/null +++ b/tests/test_fastmcp_bootstrap.py @@ -0,0 +1,23 @@ +import pytest +from fastmcp import FastMCP + +from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig +from tests.conftest import emulate_package_missing + + +def test_fastmcp_config_default_application() -> None: + config = FastMcpConfig() + assert isinstance(config.application, FastMCP) + + +def test_fastmcp_bootstrap_returns_same_application() -> None: + config = FastMcpConfig(service_name="test-mcp", service_version="1.2.3") + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + assert application is config.application + bootstrapper.teardown() + + +def test_fastmcp_bootstrapper_not_ready() -> None: + with emulate_package_missing("fastmcp"), pytest.raises(RuntimeError, match="fastmcp is not installed"): + FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) From 070408fbf3d3524c330d701666b5cc1ead6935b4 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 22:22:07 +0300 Subject: [PATCH 04/19] test: verify FastMcpBootstrapper teardown resets state --- tests/test_fastmcp_bootstrap.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 8e1ddb0..c4b4a2e 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -21,3 +21,11 @@ def test_fastmcp_bootstrap_returns_same_application() -> None: def test_fastmcp_bootstrapper_not_ready() -> None: with emulate_package_missing("fastmcp"), pytest.raises(RuntimeError, match="fastmcp is not installed"): FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + + +def test_fastmcp_teardown_resets_is_bootstrapped() -> None: + bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + bootstrapper.bootstrap() + assert bootstrapper.is_bootstrapped is True + bootstrapper.teardown() + assert bootstrapper.is_bootstrapped is False From a9a3842c3ae68c9999e0954725ee5c290ea64517 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:18:13 +0300 Subject: [PATCH 05/19] feat: add FastMcpLoggingMiddleware --- .../bootstrappers/fastmcp_bootstrapper.py | 37 +++++++++++ tests/test_fastmcp_bootstrap.py | 66 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index b11d1bc..62202cb 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -1,4 +1,5 @@ import dataclasses +import time import typing from lite_bootstrap import import_checker @@ -12,12 +13,48 @@ if import_checker.is_fastmcp_installed: from fastmcp import FastMCP + from fastmcp.server.middleware import Middleware, MiddlewareContext + +if import_checker.is_structlog_installed: + import structlog + + fastmcp_access_logger: typing.Final = structlog.get_logger("mcp.access") def _make_fastmcp() -> "FastMCP[typing.Any]": return FastMCP() +class FastMcpLoggingMiddleware(Middleware): + async def on_message( + self, + context: "MiddlewareContext[typing.Any]", + call_next: "typing.Callable[[MiddlewareContext[typing.Any]], typing.Awaitable[typing.Any]]", + ) -> typing.Any: # noqa: ANN401 + start_time = time.perf_counter_ns() + mcp_fields = { + "method": context.method, + "source": context.source, + "type": context.type, + } + try: + result = await call_next(context) + except Exception: + fastmcp_access_logger.exception( + context.method or "unknown", + mcp=mcp_fields, + duration=time.perf_counter_ns() - start_time, + ) + raise + + fastmcp_access_logger.info( + context.method or "unknown", + mcp=mcp_fields, + duration=time.perf_counter_ns() - start_time, + ) + return result + + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig): application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index c4b4a2e..94868a9 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -1,7 +1,12 @@ +import typing +from unittest.mock import MagicMock + import pytest from fastmcp import FastMCP +from fastmcp.server.middleware import MiddlewareContext from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig +from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware from tests.conftest import emulate_package_missing @@ -29,3 +34,64 @@ def test_fastmcp_teardown_resets_is_bootstrapped() -> None: assert bootstrapper.is_bootstrapped is True bootstrapper.teardown() assert bootstrapper.is_bootstrapped is False + + +async def test_fastmcp_logging_middleware_logs_success(monkeypatch: pytest.MonkeyPatch) -> None: + fake_logger = MagicMock() + monkeypatch.setattr( + "lite_bootstrap.bootstrappers.fastmcp_bootstrapper.fastmcp_access_logger", + fake_logger, + ) + middleware = FastMcpLoggingMiddleware() + context = MiddlewareContext( + message={"payload": "test"}, + method="tools/list", + source="client", + type="request", + ) + + async def call_next(received: MiddlewareContext[typing.Any]) -> dict[str, str]: + assert received is context + return {"status": "ok"} + + result = await middleware.on_message(context, call_next) + + assert result == {"status": "ok"} + fake_logger.info.assert_called_once() + call_kwargs = fake_logger.info.call_args + assert call_kwargs.args[0] == "tools/list" + assert call_kwargs.kwargs["mcp"] == { + "method": "tools/list", + "source": "client", + "type": "request", + } + assert isinstance(call_kwargs.kwargs["duration"], int) + + +async def test_fastmcp_logging_middleware_logs_exception(monkeypatch: pytest.MonkeyPatch) -> None: + fake_logger = MagicMock() + monkeypatch.setattr( + "lite_bootstrap.bootstrappers.fastmcp_bootstrapper.fastmcp_access_logger", + fake_logger, + ) + middleware = FastMcpLoggingMiddleware() + context = MiddlewareContext( + message={"payload": "test"}, + method="tools/call", + source="client", + type="request", + ) + + class CustomError(RuntimeError): + pass + + error_message = "boom" + + async def call_next(_: MiddlewareContext[typing.Any]) -> None: + raise CustomError(error_message) + + with pytest.raises(CustomError, match="boom"): + await middleware.on_message(context, call_next) + + fake_logger.exception.assert_called_once() + fake_logger.info.assert_not_called() From 466c01eedd4de06aae05806c2ceac85af5154b89 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:21:46 +0300 Subject: [PATCH 06/19] fix: guard FastMcpLoggingMiddleware class definition behind fastmcp import --- .../bootstrappers/fastmcp_bootstrapper.py | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 62202cb..418de4b 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -25,34 +25,36 @@ def _make_fastmcp() -> "FastMCP[typing.Any]": return FastMCP() -class FastMcpLoggingMiddleware(Middleware): - async def on_message( - self, - context: "MiddlewareContext[typing.Any]", - call_next: "typing.Callable[[MiddlewareContext[typing.Any]], typing.Awaitable[typing.Any]]", - ) -> typing.Any: # noqa: ANN401 - start_time = time.perf_counter_ns() - mcp_fields = { - "method": context.method, - "source": context.source, - "type": context.type, - } - try: - result = await call_next(context) - except Exception: - fastmcp_access_logger.exception( +if import_checker.is_fastmcp_installed: + + class FastMcpLoggingMiddleware(Middleware): + async def on_message( + self, + context: "MiddlewareContext[typing.Any]", + call_next: "typing.Callable[[MiddlewareContext[typing.Any]], typing.Awaitable[typing.Any]]", + ) -> typing.Any: # noqa: ANN401 + start_time = time.perf_counter_ns() + mcp_fields = { + "method": context.method, + "source": context.source, + "type": context.type, + } + try: + result = await call_next(context) + except Exception: + fastmcp_access_logger.exception( + context.method or "unknown", + mcp=mcp_fields, + duration=time.perf_counter_ns() - start_time, + ) + raise + + fastmcp_access_logger.info( context.method or "unknown", mcp=mcp_fields, duration=time.perf_counter_ns() - start_time, ) - raise - - fastmcp_access_logger.info( - context.method or "unknown", - mcp=mcp_fields, - duration=time.perf_counter_ns() - start_time, - ) - return result + return result @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) From 7014b415d6ab24fab6f8b16a04055be0ce841d77 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:25:32 +0300 Subject: [PATCH 07/19] feat: add FastMcpHealthChecksInstrument --- .../bootstrappers/fastmcp_bootstrapper.py | 23 +++++++- tests/test_fastmcp_bootstrap.py | 55 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 418de4b..a6210d8 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -4,7 +4,7 @@ from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper -from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig +from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument from lite_bootstrap.instruments.logging_instrument import LoggingConfig from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig @@ -14,6 +14,8 @@ if import_checker.is_fastmcp_installed: from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext + from starlette.requests import Request + from starlette.responses import JSONResponse if import_checker.is_structlog_installed: import structlog @@ -63,10 +65,27 @@ class FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, Pyrosco logging_turn_off_middleware: bool = False +@dataclasses.dataclass(kw_only=True) +class FastMcpHealthChecksInstrument(HealthChecksInstrument): + bootstrap_config: FastMcpConfig + + def bootstrap(self) -> None: + @self.bootstrap_config.application.custom_route( + self.bootstrap_config.health_checks_path, + methods=["GET"], + name="health_check", + include_in_schema=self.bootstrap_config.health_checks_include_in_schema, + ) + async def health_check_handler(_: "Request") -> "JSONResponse": + return JSONResponse(dict(self.render_health_check_data())) + + class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): __slots__ = "bootstrap_config", "instruments" - instruments_types: typing.ClassVar = [] + instruments_types: typing.ClassVar = [ + FastMcpHealthChecksInstrument, + ] bootstrap_config: FastMcpConfig not_ready_message = "fastmcp is not installed" diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 94868a9..315d6ee 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -4,6 +4,8 @@ import pytest from fastmcp import FastMCP from fastmcp.server.middleware import MiddlewareContext +from starlette import status +from starlette.testclient import TestClient from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware @@ -95,3 +97,56 @@ async def call_next(_: MiddlewareContext[typing.Any]) -> None: fake_logger.exception.assert_called_once() fake_logger.info.assert_not_called() + + +def _make_test_config(**overrides: typing.Any) -> FastMcpConfig: # noqa: ANN401 + base: dict[str, typing.Any] = { + "service_name": "test-mcp", + "service_version": "1.2.3", + "logging_buffer_capacity": 0, + } + base.update(overrides) + return FastMcpConfig(**base) + + +def test_fastmcp_health_check_route_serves_200_with_data() -> None: + config = _make_test_config() + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + with TestClient(application.http_app()) as test_client: + response = test_client.get(config.health_checks_path) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "health_status": True, + "service_name": "test-mcp", + "service_version": "1.2.3", + } + finally: + bootstrapper.teardown() + + +def test_fastmcp_health_check_path_is_configurable() -> None: + config = _make_test_config(health_checks_path="/healthz") + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + with TestClient(application.http_app()) as test_client: + response = test_client.get("/healthz") + default_response = test_client.get("/health/") + assert response.status_code == status.HTTP_200_OK + assert default_response.status_code == status.HTTP_404_NOT_FOUND + finally: + bootstrapper.teardown() + + +def test_fastmcp_health_check_disabled_when_flag_false() -> None: + config = _make_test_config(health_checks_enabled=False) + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + with TestClient(application.http_app()) as test_client: + response = test_client.get(config.health_checks_path) + assert response.status_code == status.HTTP_404_NOT_FOUND + finally: + bootstrapper.teardown() From f7d1b8bd9683279f037f9bb8cbceb32f73181024 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:28:33 +0300 Subject: [PATCH 08/19] feat: add FastMcpPrometheusInstrument --- .../bootstrappers/fastmcp_bootstrapper.py | 31 +++++++++++++++- tests/test_fastmcp_bootstrap.py | 37 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index a6210d8..a8644ff 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -6,7 +6,7 @@ from lite_bootstrap.bootstrappers.base import BaseBootstrapper from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument from lite_bootstrap.instruments.logging_instrument import LoggingConfig -from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig +from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig from lite_bootstrap.instruments.sentry_instrument import SentryConfig @@ -15,13 +15,16 @@ from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext from starlette.requests import Request - from starlette.responses import JSONResponse + from starlette.responses import JSONResponse, Response if import_checker.is_structlog_installed: import structlog fastmcp_access_logger: typing.Final = structlog.get_logger("mcp.access") +if import_checker.is_prometheus_client_installed: + import prometheus_client + def _make_fastmcp() -> "FastMCP[typing.Any]": return FastMCP() @@ -80,11 +83,35 @@ async def health_check_handler(_: "Request") -> "JSONResponse": return JSONResponse(dict(self.render_health_check_data())) +@dataclasses.dataclass(kw_only=True) +class FastMcpPrometheusInstrument(PrometheusInstrument): + bootstrap_config: FastMcpConfig + missing_dependency_message = "prometheus_client is not installed" + + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_prometheus_client_installed + + def bootstrap(self) -> None: + @self.bootstrap_config.application.custom_route( + self.bootstrap_config.prometheus_metrics_path, + methods=["GET"], + name="metrics", + include_in_schema=self.bootstrap_config.prometheus_metrics_include_in_schema, + ) + async def metrics_handler(_: "Request") -> "Response": + return Response( + prometheus_client.generate_latest(prometheus_client.REGISTRY), + headers={"content-type": prometheus_client.CONTENT_TYPE_LATEST}, + ) + + class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): __slots__ = "bootstrap_config", "instruments" instruments_types: typing.ClassVar = [ FastMcpHealthChecksInstrument, + FastMcpPrometheusInstrument, ] bootstrap_config: FastMcpConfig not_ready_message = "fastmcp is not installed" diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 315d6ee..6a0083a 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -1,6 +1,7 @@ import typing from unittest.mock import MagicMock +import prometheus_client import pytest from fastmcp import FastMCP from fastmcp.server.middleware import MiddlewareContext @@ -150,3 +151,39 @@ def test_fastmcp_health_check_disabled_when_flag_false() -> None: assert response.status_code == status.HTTP_404_NOT_FOUND finally: bootstrapper.teardown() + + +def test_fastmcp_prometheus_route_exposes_registered_metric() -> None: + counter_name = "fastmcp_plan_test_requests_total" + try: + counter = prometheus_client.Counter(counter_name, "FastMCP plan test counter.") + except ValueError: + collector = prometheus_client.REGISTRY._names_to_collectors[counter_name] # noqa: SLF001 + counter = typing.cast(prometheus_client.Counter, collector) + counter.inc() + + config = _make_test_config() + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + with TestClient(application.http_app()) as test_client: + response = test_client.get(config.prometheus_metrics_path) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-type"].startswith(prometheus_client.CONTENT_TYPE_LATEST.split(";")[0]) + assert counter_name.encode() in response.content + finally: + bootstrapper.teardown() + + +def test_fastmcp_prometheus_path_is_configurable() -> None: + config = _make_test_config(prometheus_metrics_path="/m") + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + with TestClient(application.http_app()) as test_client: + response = test_client.get("/m") + default_response = test_client.get("/metrics") + assert response.status_code == status.HTTP_200_OK + assert default_response.status_code == status.HTTP_404_NOT_FOUND + finally: + bootstrapper.teardown() From a54f51f2465ba3204debbdcdb184d263aa029722 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:28:37 +0300 Subject: [PATCH 09/19] docs: add instrument-skip-rework design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design captured for replacing InstrumentNotReadyWarning with a pre- instantiation `is_configured` classmethod check + structured skipped_instruments introspection + single INFO summary log. The current post-PR-#86 behavior fires a warning every time an instrument's is_ready() returns False — typically a user-config opt-out, not an anomaly. Every service that uses a subset of available instruments emits multiple warnings on bootstrap; tests can't suppress without breaking the warning assertions. The redesign moves the config check before check_dependencies, makes it a classmethod (preserves PR #88's no-instantiation-on-missing-dep constraint), and removes the warning entirely. dep-missing still warns (genuine deployment surprise). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-06-01-instrument-skip-rework-design.md | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md diff --git a/docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md b/docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md new file mode 100644 index 0000000..44c452a --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md @@ -0,0 +1,276 @@ +# Design: Instrument Skip Rework — Replace `InstrumentNotReadyWarning` With Pre-Instantiation Config Check + Summary Log + +**Date:** 2026-06-01 +**Status:** Approved (design complete; implementation pending) + +## Problem + +PR #86 (commit `992b3db`, "feat: unify instrument skip feedback through warning subclasses") added +`InstrumentNotReadyWarning` as a sibling to `InstrumentDependencyMissingWarning`. Both fire from +`BaseBootstrapper._register_or_skip` when an instrument is skipped — `InstrumentNotReadyWarning` +specifically fires when an instrument's `is_ready()` instance method returns False, which is +typically a user-config decision (`sentry_dsn` empty, `pyroscope_endpoint` empty, `logging_enabled` False, etc.). + +The change escalated the not-ready path from `logger.info` (pre-#86, silently dropped because logging +hadn't bootstrapped yet) to a real `warnings.warn` call. The escalation surfaces an expected-path +event as a warning, generating noise in every downstream service that uses a subset of the +available instruments. Services must add `warnings.filterwarnings` to suppress; lite-bootstrap's own +test suite asserts on these warnings with `pytest.warns(InstrumentNotReadyWarning)`, so suppression +can't be global. + +A second, related issue: even pre-#86, `InstrumentDependencyMissingWarning` fires for unconfigured +instruments when the user didn't install the optional extra. Example: install `lite-bootstrap[fastapi]` +without `[sentry]`, never configure Sentry → `SentryInstrument.check_dependencies()` returns False → +`InstrumentDependencyMissingWarning` fires. The user didn't want Sentry; the warning is noise. + +Root cause: the bootstrapper runs `check_dependencies()` before checking whether the user wanted +the instrument. PR #88 (commit `b5e00a2`) made the order strict — `check_dependencies()` MUST run +before instantiation because `FastStreamPrometheusInstrument` has a `default_factory` that calls +`prometheus_client.CollectorRegistry()`, which NameErrors when `prometheus_client` isn't installed. + +The post-#86 flow: + +1. `check_dependencies()` — static method on type. Fires `InstrumentDependencyMissingWarning` on False. +2. Instantiate. +3. `is_ready()` — instance method. Fires `InstrumentNotReadyWarning` on False. + +Both warnings can fire on the expected path of any service that doesn't use every available instrument. + +## Goal + +Establish a contract where warnings only fire for genuine deployment surprises (user configured an +instrument, but the dependency is missing). Skipped-due-to-config becomes silent at the warning +level, with structured introspection available on the bootstrapper instance and a single +INFO-level summary log for diagnostic visibility. + +## Design + +### API change: `is_ready` becomes `is_configured` classmethod + +`BaseInstrument` API: + +```python +class BaseInstrument(typing.Generic[ConfigT]): + bootstrap_config: ConfigT + not_ready_message: typing.ClassVar[str] = "" + missing_dependency_message: typing.ClassVar[str] = "" + + @classmethod + def is_configured(cls, bootstrap_config: ConfigT) -> bool: + """Return True if config indicates this instrument should be active. Default: always active.""" + return True + + @staticmethod + def check_dependencies() -> bool: + return True + + def bootstrap(self) -> None: ... + def teardown(self) -> None: ... +``` + +`is_configured` is a classmethod taking `bootstrap_config` because it must run before instantiation +(so PR #88's NameError constraint is preserved — no instance, no default-factory eval, no NameError). +The signature change is a one-time migration cost; the trade-off buys correct ordering. + +`is_ready` instance method: **removed**. Existing callers migrate to `XInstrument.is_configured(config)`. + +`not_ready_message`: **kept** as a class attribute on each instrument. Used as the human-readable +reason in `skipped_instruments` and the summary log. + +### Bootstrapper flow reorder + +`BaseBootstrapper.__init__` body changes: + +```python +self.instruments: list[BaseInstrument] = [] +self.skipped_instruments: list[tuple[type[BaseInstrument], str]] = [] # NEW + +for instrument_type in self.instruments_types: + if not instrument_type.is_configured(self.bootstrap_config): + self.skipped_instruments.append((instrument_type, instrument_type.not_ready_message)) + continue + if not instrument_type.check_dependencies(): + warnings.warn( + instrument_type.missing_dependency_message, + category=InstrumentDependencyMissingWarning, + stacklevel=3, + ) + continue + self.instruments.append(instrument_type(bootstrap_config=self.bootstrap_config)) + +logger.info( + f"{type(self).__name__}: " + f"configured={[type(i).__name__ for i in self.instruments]}, " + f"skipped={[(cls.__name__, reason) for cls, reason in self.skipped_instruments]}" +) +``` + +The `_register_or_skip` helper goes away — the flow is short enough inline. + +Order semantics: + +1. `is_configured(config)` False → silent skip + entry in `skipped_instruments`. No warning. +2. `is_configured(config)` True, `check_dependencies()` False → `InstrumentDependencyMissingWarning` + fires. This IS a genuine deployment surprise: user configured the instrument but the optional + package isn't installed. +3. Both True → instrument instantiated and registered. + +### Introspection + +The new `bootstrapper.skipped_instruments: list[tuple[type[BaseInstrument], str]]` exposes +unconfigured instruments as structured data — class object + the `not_ready_message` string. Tests +and third-party tooling consume this directly instead of warning capture. + +`bootstrapper.instruments: list[BaseInstrument]` (already exists) exposes the configured instances. + +### Summary log + +One `logger.info(...)` call at the end of `__init__`, after all decisions. Default Python logging +suppresses INFO-level by default, so users see nothing unless they `logging.basicConfig(level=logging.INFO)` +or otherwise configure their root logger. The pre-#86 "silently dropped" property is preserved by +default while keeping an opt-in path to visibility. + +The summary is one line (not per-instrument). Even if it's dropped silently in production, the +hit-count cost is constant per bootstrap call, not per-instrument. + +The `logger` is the existing module-level structlog/stdlib logger (whichever is available) in +`bootstrappers/base.py:19-21`. + +## Per-instrument migration + +All 8 base instruments + 3 framework-specific overrides. Mechanical signature change. + +| Instrument | Current `is_ready(self)` body | New `is_configured(cls, bootstrap_config)` body | +|---|---|---| +| `BaseInstrument` | (default `return True`) | (default `return True`) | +| `CorsInstrument` | `bool(self.bootstrap_config.cors_allowed_origins) or bool(self.bootstrap_config.cors_allowed_origin_regex)` | `bool(bootstrap_config.cors_allowed_origins) or bool(bootstrap_config.cors_allowed_origin_regex)` | +| `HealthChecksInstrument` | `self.bootstrap_config.health_checks_enabled` | `bootstrap_config.health_checks_enabled` | +| `LoggingInstrument` | `self.bootstrap_config.logging_enabled` | `bootstrap_config.logging_enabled` | +| `OpenTelemetryInstrument` | `bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces)` | (same w/ `bootstrap_config.`) | +| `PrometheusInstrument` | `bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path(self.bootstrap_config.prometheus_metrics_path)` | (same w/ `bootstrap_config.`) | +| `PyroscopeInstrument` | `bool(self.bootstrap_config.pyroscope_endpoint)` | `bool(bootstrap_config.pyroscope_endpoint)` | +| `SentryInstrument` | `bool(self.bootstrap_config.sentry_dsn)` | `bool(bootstrap_config.sentry_dsn)` | +| `SwaggerInstrument` | (default `return True`) | (default `return True`) | + +Framework-specific overrides: + +- `LitestarSwaggerInstrument.is_ready` → `is_configured` classmethod; same body with `bootstrap_config` arg. +- `FastStreamOpenTelemetryInstrument.is_ready`: `super().is_ready() and bool(self.bootstrap_config.opentelemetry_middleware_cls)` → `super().is_configured(bootstrap_config) and bool(bootstrap_config.opentelemetry_middleware_cls)`. +- `FastStreamPrometheusInstrument.is_ready`: `super().is_ready() and import_checker.is_prometheus_client_installed and bool(self.bootstrap_config.prometheus_middleware_cls)` → classmethod form. The `import_checker.is_prometheus_client_installed` conjunct is dead per the DES-5 audit finding (already covered by `check_dependencies`); can be dropped during migration. + +## Tests + +### `tests/test_free_bootstrap.py::test_free_bootstrap_logging_disabled` + +Current: + +```python +with pytest.warns(InstrumentNotReadyWarning) as records: + FreeBootstrapper(bootstrap_config=FreeBootstrapperConfig(logging_enabled=False, ...)) +messages = [str(r.message) for r in records] +assert "LoggingInstrument is not ready: logging_enabled is False" in messages +assert "PyroscopeInstrument is not ready: pyroscope_endpoint is empty" in messages +``` + +New: + +```python +bootstrapper = FreeBootstrapper( + bootstrap_config=FreeBootstrapperConfig(logging_enabled=False, ...), +) +skipped_classes = {cls for cls, _ in bootstrapper.skipped_instruments} +assert LoggingInstrument in skipped_classes +assert PyroscopeInstrument in skipped_classes +``` + +### `tests/instruments/test_*_instrument.py` (PR10 additions) + +`assert not instrument.is_ready()` → `assert not XInstrument.is_configured(config)`. +`assert instrument.not_ready_message == "..."` → `assert XInstrument.not_ready_message == "..."` +(class attribute access; functionally identical). + +Affected files: `test_cors_instrument.py`, `test_healthchecks_instrument.py`, `test_prometheus_instrument.py`, `test_pyroscope_instrument.py`, `test_swagger_instrument.py`. + +### Existing tests that continue to work unchanged + +`test_fastapi_bootstrapper_with_missing_instrument_dependency` and its +`{litestar,faststream,free}` siblings exercise the dep-missing warning path. Under the new flow, +they still pass: when a configured instrument's optional dep is missing, the warning fires as +before. The reorder doesn't affect them — these tests use a config that DOES configure the +relevant instrument (`is_configured` returns True), so the dep check runs. + +### Optional new test: summary log assertion + +A small test in `test_free_bootstrap.py` using pytest's `caplog` fixture can pin the new INFO +summary log behavior: + +```python +def test_bootstrap_emits_summary_log(caplog) -> None: + with caplog.at_level(logging.INFO, logger="lite_bootstrap.bootstrappers.base"): + FreeBootstrapper(bootstrap_config=FreeBootstrapperConfig(sentry_dsn="https://x@y/1")) + assert any("FreeBootstrapper" in r.message and "configured=" in r.message for r in caplog.records) +``` + +Worth adding to prevent silent regression where someone deletes the summary call. + +### Cross-check + +Pre-flight grep before implementation to confirm all `is_ready()` call sites: + +```bash +grep -rn "\.is_ready(" lite_bootstrap/ tests/ --include="*.py" +``` + +Expected: only the migration sites listed above. Any user-visible API contract change beyond the +listed call sites is out of scope and should be flagged. + +## Exports / public API + +`lite_bootstrap/exceptions.py`: +- **Remove** `InstrumentNotReadyWarning` class (hard delete; 4 weeks old, acceptable churn). +- **Keep** `InstrumentSkippedWarning` (base; document as forward-compat for additional skip categories) and `InstrumentDependencyMissingWarning` (subclass). + +`lite_bootstrap/__init__.py`: +- Remove `InstrumentNotReadyWarning` from imports and `__all__`. +- `InstrumentSkippedWarning` and `InstrumentDependencyMissingWarning` remain exported. + +`BaseBootstrapper.skipped_instruments` becomes a new public attribute. No deprecation needed for `instruments` — unchanged. + +## Documentation + +- `docs/introduction/configuration.md` — PR #86 added a 31-line section about the warning subclasses. Revise to: only `InstrumentDependencyMissingWarning` remains; not-configured instruments live in `bootstrapper.skipped_instruments` and the startup INFO log. +- `CLAUDE.md` — update the "Key design decisions" section to mention the `is_configured` classmethod precondition and the silent-skip-vs-warn distinction. Add a line to the `Optional dependencies` bullet noting that the new is_configured → check_dependencies order means missing-dep warnings only fire for instruments the user configured. + +## Backward compatibility + +`InstrumentNotReadyWarning` is publicly exported and was introduced 4 weeks ago. Hard removal: +anyone importing it from `lite_bootstrap` will get `ImportError` after this lands. + +Decision: hard remove, no deprecation period. Consistent with the framing ("this was a bad idea"). + +`is_ready` instance method removal: also hard. Any user calling `instrument.is_ready()` will get +`AttributeError`. The call pattern is rare in user code (it's library-internal lifecycle). + +Migration path for any user who hits these: rename `from lite_bootstrap import InstrumentNotReadyWarning` +imports to remove the line; replace `instrument.is_ready()` calls with `type(instrument).is_configured(config)`. + +## Out of scope + +- `InstrumentSkippedWarning` removal — only one subclass left after this PR, but keeping the base + is cheap forward-compat. If another skip category arises later, it slots in cleanly. +- Per-bootstrapper logger configuration — the summary log uses the existing module-level logger. + Customizing the log format/destination is a separate concern. +- Adding skipped-with-dep-missing to `skipped_instruments` data — only `is_configured`-skipped + instruments live there. The dep-missing path still fires its warning (deployment surprise) and + also doesn't populate `instruments`. Could be added later if there's demand. + +## Risk + +Medium. The cascade touches all instrument files, the bootstrapper base, exceptions, tests, and +public docs. The `is_configured` signature change is the migration cost; the bootstrapper reorder +is the substantive design improvement. + +Mitigation: existing test suite covers all framework integration paths (FastAPI, Litestar, FastStream, +Free). After migration the suite must pass without `pytest.warns(InstrumentNotReadyWarning)` assertions +anywhere (one such site removed in `test_free_bootstrap.py`). Pre-flight grep for `is_ready` call +sites catches any missed migration. From 39203449307d0405af7ef9ece928ac8d719c9197 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:31:37 +0300 Subject: [PATCH 10/19] feat: add FastMcpLoggingInstrument with MCP access middleware --- .../bootstrappers/fastmcp_bootstrapper.py | 22 ++++++++++++++--- tests/test_fastmcp_bootstrap.py | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index a8644ff..c87c57d 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -5,10 +5,10 @@ from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument -from lite_bootstrap.instruments.logging_instrument import LoggingConfig +from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument -from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig -from lite_bootstrap.instruments.sentry_instrument import SentryConfig +from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument +from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument if import_checker.is_fastmcp_installed: @@ -106,11 +106,27 @@ async def metrics_handler(_: "Request") -> "Response": ) +@dataclasses.dataclass(kw_only=True) +class FastMcpLoggingInstrument(LoggingInstrument): + bootstrap_config: FastMcpConfig + + def bootstrap(self) -> None: + super().bootstrap() + if self.bootstrap_config.logging_turn_off_middleware: + return + if not import_checker.is_structlog_installed: + return + self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) + + class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): __slots__ = "bootstrap_config", "instruments" instruments_types: typing.ClassVar = [ + PyroscopeInstrument, + SentryInstrument, FastMcpHealthChecksInstrument, + FastMcpLoggingInstrument, FastMcpPrometheusInstrument, ] bootstrap_config: FastMcpConfig diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 6a0083a..7f80006 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -187,3 +187,27 @@ def test_fastmcp_prometheus_path_is_configurable() -> None: assert default_response.status_code == status.HTTP_404_NOT_FOUND finally: bootstrapper.teardown() + + +def _find_mcp_logging_middleware(application: "FastMCP") -> list[FastMcpLoggingMiddleware]: + return [m for m in application.middleware if isinstance(m, FastMcpLoggingMiddleware)] + + +def test_fastmcp_logging_middleware_is_mounted_by_default() -> None: + config = _make_test_config() + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + assert len(_find_mcp_logging_middleware(application)) == 1 + finally: + bootstrapper.teardown() + + +def test_fastmcp_logging_middleware_disabled_via_flag() -> None: + config = _make_test_config(logging_turn_off_middleware=True) + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + assert _find_mcp_logging_middleware(application) == [] + finally: + bootstrapper.teardown() From 23f705dc8cf7ef18753905230953c1e2318f5ad7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:34:52 +0300 Subject: [PATCH 11/19] test: cover missing optional dependencies for FastMcpBootstrapper Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_fastmcp_bootstrap.py | 44 ++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 7f80006..5cb8443 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -10,7 +10,7 @@ from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware -from tests.conftest import emulate_package_missing +from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload def test_fastmcp_config_default_application() -> None: @@ -211,3 +211,45 @@ def test_fastmcp_logging_middleware_disabled_via_flag() -> None: assert _find_mcp_logging_middleware(application) == [] finally: bootstrapper.teardown() + + +@pytest.mark.parametrize( + "package_name", + [ + "sentry_sdk", + "structlog", + "prometheus_client", + ], +) +def test_fastmcp_bootstrapper_with_missing_instrument_dependency(package_name: str) -> None: + with emulate_package_missing(package_name), pytest.warns(UserWarning, match=package_name): + FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + + +def test_fastmcp_bootstrap_without_prometheus_client() -> None: + # Regression guard mirroring the FastStream prometheus-missing test: ensures + # FastMcpPrometheusInstrument.check_dependencies() prevents construction-time + # failure when prometheus_client is absent. + with emulate_package_missing_with_module_reload( + "prometheus_client", + ["lite_bootstrap.bootstrappers.fastmcp_bootstrapper"], + ): + with pytest.warns(UserWarning, match="prometheus_client"): + bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + bootstrapper.bootstrap() + bootstrapper.teardown() + + +def test_fastmcp_bootstrap_without_structlog() -> None: + # Regression guard: FastMcpLoggingInstrument.bootstrap() must short-circuit + # the middleware registration when structlog is absent, because the + # middleware references fastmcp_access_logger which only exists inside + # the structlog guard. + with emulate_package_missing_with_module_reload( + "structlog", + ["lite_bootstrap.bootstrappers.fastmcp_bootstrapper"], + ): + with pytest.warns(UserWarning, match="structlog"): + bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + bootstrapper.bootstrap() + bootstrapper.teardown() From c7682eb1b1400a4e7774bd4b234a8b170ff96fc7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:36:18 +0300 Subject: [PATCH 12/19] docs: update fastmcp spec/plan to reflect manual-teardown discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original design aimed to wrap FastMCP.lifespan with combine_lifespans to wire teardown automatically. Implementation surfaced that FastMCP.lifespan is a read-only bound method on AggregateProvider and the runtime hook is the private _lifespan attribute (set at constructor time only). The spec's documented fallback — "teardown is manual" — is now the adopted approach. Updates: - Spec: §"Teardown wiring risk" → §"Teardown is manual"; tests list drops ASGI-lifespan and user-lifespan-preserve tests, adds test_fastmcp_teardown_resets_is_bootstrapped. - Plan: Task 4 module skeleton no longer mutates lifespan; Task 5 reduced to single teardown-reset assertion; integrations page documents how users wire teardown themselves. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-06-01-fastmcp-bootstrapper.md | 123 ++++++------------ .../2026-06-01-fastmcp-bootstrapper-design.md | 31 +++-- 2 files changed, 57 insertions(+), 97 deletions(-) diff --git a/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md index 7276ccf..1d2e9b2 100644 --- a/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md +++ b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md @@ -35,7 +35,7 @@ Two new files, six modified files. - **Instrument set:** Sentry, Pyroscope, structlog logging + MCP middleware, health, prometheus. No OTel/CORS/Swagger. - **Middleware default:** mounted on; `logging_turn_off_middleware: bool = False` flag to opt out. - **Prometheus route:** `application.custom_route` at `prometheus_metrics_path`, always-on (no per-bootstrapper opt-out flag). -- **Teardown:** wrap `FastMCP.lifespan` via `combine_lifespans`. Risk: assumes mutability of `FastMCP.lifespan` — guarded by the lifespan-replay test in Task 10. +- **Teardown:** **manual** (user calls `bootstrapper.teardown()` themselves). Originally planned to wrap `FastMCP.lifespan` via `combine_lifespans`, but discovered empirically that `app.lifespan` is a read-only bound method and the real hook is `app._lifespan` (private). Documented fallback adopted; spec §"Teardown is manual" updated. - **Extras:** only `fastmcp` and `fastmcp-metrics`. No `fastmcp-sentry` / `fastmcp-logging` / `fastmcp-all` because they would not pull in a new direct dependency. - **Default config app:** `default_factory=_make_fastmcp` where `_make_fastmcp()` returns `FastMCP()`. No `UnsetType` sentinel — `FastMCP()` needs no derived config. - **Middleware default registry:** `prometheus_client.REGISTRY`. No fastmcp-specific registry config field. @@ -221,7 +221,6 @@ Expected: All three tests fail with `ImportError: cannot import name 'FastMcpBoo - [ ] Create `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` with the following content: ```python -import contextlib import dataclasses import time import typing @@ -238,7 +237,6 @@ from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryIns if import_checker.is_fastmcp_installed: from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext - from fastmcp.utilities.lifespan import combine_lifespans from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -255,24 +253,6 @@ def _make_fastmcp() -> "FastMCP[typing.Any]": return FastMCP() -@contextlib.asynccontextmanager -async def _empty_lifespan(_: "FastMCP[typing.Any]") -> typing.AsyncIterator[dict[str, typing.Any]]: - yield {} - - -def _build_teardown_lifespan( - teardown: typing.Callable[[], None], -) -> typing.Callable[["FastMCP[typing.Any]"], typing.AsyncContextManager[dict[str, typing.Any]]]: - @contextlib.asynccontextmanager - async def lifespan(_: "FastMCP[typing.Any]") -> typing.AsyncIterator[dict[str, typing.Any]]: - try: - yield {} - finally: - teardown() - - return lifespan - - @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class FastMcpConfig( HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig @@ -291,16 +271,12 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): def is_ready(self) -> bool: return import_checker.is_fastmcp_installed - def __init__(self, bootstrap_config: FastMcpConfig) -> None: - super().__init__(bootstrap_config) - application = self.bootstrap_config.application - existing_lifespan = application.lifespan if application.lifespan is not None else _empty_lifespan - application.lifespan = combine_lifespans(existing_lifespan, _build_teardown_lifespan(self.teardown)) - def _prepare_application(self) -> "FastMCP[typing.Any]": return self.bootstrap_config.application ``` +Note: no `__init__` override and no lifespan wiring. `FastMCP.lifespan` was empirically determined to be a read-only bound method, with the real hook stored in private `_lifespan` at construction time. Per the spec's documented fallback, teardown is manual — users call `bootstrapper.teardown()` themselves. + ### Step 4: Re-export from package __init__ - [ ] Update `lite_bootstrap/__init__.py`. Insert imports alphabetically (after the existing `faststream_bootstrapper` import) and add the names to `__all__`: @@ -344,80 +320,37 @@ git commit -m "feat: scaffold FastMcpBootstrapper and FastMcpConfig" --- -## Task 5: Verify teardown via ASGI lifespan +## Task 5: Verify manual teardown resets is_bootstrapped -Adds the lifespan-replay test that exercises `FastMCP.lifespan` mutation end-to-end. This proves the (currently empty-instrument) bootstrapper's teardown wiring works before we layer instruments on top of it. +Originally planned as an ASGI-lifespan replay test, but the spec's "Teardown wiring risk" materialized — see the locked-decisions section. Teardown is manual. This task simply adds a regression test confirming `bootstrapper.teardown()` toggles `is_bootstrapped` correctly. (The existing `test_fastmcp_bootstrap_returns_same_application` test already calls `teardown()` at the end; this task makes the assertion explicit.) **Files:** - Modify: `tests/test_fastmcp_bootstrap.py` -### Step 1: Write the failing tests +### Step 1: Write the failing test - [ ] Append to `tests/test_fastmcp_bootstrap.py`: ```python -async def _drive_asgi_lifespan(application: typing.Any) -> list[dict[str, typing.Any]]: - """Drive an ASGI lifespan from startup through shutdown. Returns the sent messages.""" - inbox = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}] - outbox: list[dict[str, typing.Any]] = [] - - async def receive() -> dict[str, typing.Any]: - return inbox.pop(0) - - async def send(message: dict[str, typing.Any]) -> None: - outbox.append(message) - - await application({"type": "lifespan", "asgi": {"version": "3.0"}}, receive, send) - return outbox - - -async def test_fastmcp_teardown_runs_via_asgi_lifespan() -> None: +def test_fastmcp_teardown_resets_is_bootstrapped() -> None: bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) - application = bootstrapper.bootstrap() - assert bootstrapper.is_bootstrapped - - http_app = application.http_app() - sent = await _drive_asgi_lifespan(http_app) - - assert any(message["type"] == "lifespan.startup.complete" for message in sent) - assert any(message["type"] == "lifespan.shutdown.complete" for message in sent) - assert not bootstrapper.is_bootstrapped - - -async def test_fastmcp_existing_user_lifespan_is_preserved() -> None: - user_state: dict[str, bool] = {"startup": False, "shutdown": False} - - @contextlib.asynccontextmanager - async def user_lifespan(_: FastMCP) -> typing.AsyncIterator[dict[str, typing.Any]]: - user_state["startup"] = True - try: - yield {} - finally: - user_state["shutdown"] = True - - config = FastMcpConfig(application=FastMCP(lifespan=user_lifespan)) - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - - http_app = application.http_app() - await _drive_asgi_lifespan(http_app) - - assert user_state["startup"] is True - assert user_state["shutdown"] is True - assert not bootstrapper.is_bootstrapped + bootstrapper.bootstrap() + assert bootstrapper.is_bootstrapped is True + bootstrapper.teardown() + assert bootstrapper.is_bootstrapped is False ``` ### Step 2: Run tests to verify pass -Run: `just test -- tests/test_fastmcp_bootstrap.py -v` +Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_teardown_resets_is_bootstrapped -v` -Expected: both new tests pass. If `application.lifespan` mutation rejects (`AttributeError` during `FastMcpBootstrapper.__init__`), the design's stated risk has materialized; stop and re-open the design (the fallback would be to wrap on `_prepare_application` instead). +Expected: passes immediately (the assertions only exercise the base class's idempotent teardown plumbing, which already works). ### Step 3: Commit ```bash git add tests/test_fastmcp_bootstrap.py -git commit -m "test: verify FastMcpBootstrapper teardown via ASGI lifespan" +git commit -m "test: verify FastMcpBootstrapper teardown resets state" ``` --- @@ -1035,6 +968,34 @@ def greet_person(person_name: str) -> str: Set `logging_turn_off_middleware=True` on the config to disable the per-MCP-message access log middleware. Set `health_checks_enabled=False` to omit the health route. +## 3. Teardown + +`FastMcpBootstrapper` does not wire teardown automatically (FastMCP captures its +lifespan at construction time only). Call `bootstrapper.teardown()` yourself +during shutdown — typically from a `lifespan=` callable you pass to `FastMCP`, +from an ASGI shutdown handler, or via `atexit`: + +```python +import contextlib +from fastmcp import FastMCP + + +@contextlib.asynccontextmanager +async def lifespan(app: FastMCP): + try: + yield + finally: + bootstrapper.teardown() + + +bootstrapper_config = FastMcpConfig( + service_name="microservice", + application=FastMCP(lifespan=lifespan), +) +bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config) +application = bootstrapper.bootstrap() +``` + Read more about available configuration options [here](../../../introduction/configuration): ```` diff --git a/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md b/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md index 8ea8a5e..0e67117 100644 --- a/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md +++ b/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md @@ -12,7 +12,8 @@ This is a design spec, not an implementation plan. Per-PR sequencing and task-by - Match the behavior shipped by microbootstrap PR141: Sentry, Pyroscope, structlog-based logging (with an MCP-aware access middleware), an HTTP health endpoint, and an HTTP Prometheus endpoint. - Match the lite-bootstrap architectural conventions (frozen-dataclass configs composed via multiple inheritance, framework-subclass instruments, `instruments_types` ClassVar, optional-import guards via `import_checker`). -- Improve over PR141 by wiring `teardown()` through the FastMCP lifespan rather than relying on the user to call it manually. + +> **Update (2026-06-01, during execution):** the design originally aimed to *improve* over PR141 by wiring `teardown()` through the FastMCP lifespan. Implementation discovered that `FastMCP.lifespan` is a read-only bound method and the runtime hook is the private `_lifespan` attribute (the lifespan is captured at constructor time via the `lifespan=` kwarg). Post-construction wiring would require either touching `_lifespan` (private API, fragile) or rebuilding the user's `FastMCP` instance (intrusive). The risk documented under §"Teardown wiring risk" materialized; per the documented fallback, teardown is now manual — matching microbootstrap PR141's posture. See the updated §"Bootstrapper" and §"Tests" sections below. ## Non-goals @@ -80,7 +81,7 @@ Instrument order (Pyroscope → Sentry → Health → Logging → Prometheus) mi - **Custom HTTP routes**: `@application.custom_route(path, methods=["GET"], name=..., include_in_schema=...)` registers Starlette-style handlers that surface on `application.http_app()`. Custom routes bypass `AuthProvider`, which is correct behavior for health and metrics endpoints. - **MCP protocol middleware**: `application.add_middleware(middleware_instance)` registers a FastMCP `Middleware` subclass that runs on every MCP message (tools/list, tool/call, resources/list, etc.). This is distinct from Starlette HTTP middleware passed via `application.http_app(middleware=[...])`. We use the former because we want per-method MCP-level access logs. -- **Lifespan**: `FastMCP(lifespan=...)` accepts an `@asynccontextmanager` callable, and FastMCP exposes `fastmcp.utilities.lifespan.combine_lifespans(*lifespans)` for stacking. The lifespan is invoked when the user calls `application.http_app()` (or `application.run(transport="http")`). For stdio transport the lifespan is invoked as well, per FastMCP's transport runner. +- **Lifespan (not used)**: `FastMCP(lifespan=...)` accepts an `@asynccontextmanager` callable at construction time only. The post-construction `app.lifespan` attribute is a read-only bound method, and the actual runtime hook is `app._lifespan` (private). The bootstrapper therefore does **not** wire teardown via lifespan composition. Users who need deterministic shutdown call `bootstrapper.teardown()` themselves (e.g., in their own `lifespan=` callable, an ASGI shutdown handler, or `atexit`). --- @@ -166,23 +167,23 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): def is_ready(self) -> bool: return import_checker.is_fastmcp_installed - def __init__(self, bootstrap_config: FastMcpConfig) -> None: - super().__init__(bootstrap_config) - application = self.bootstrap_config.application - application.lifespan = combine_lifespans(application.lifespan, _build_teardown_lifespan(self.teardown)) - def _prepare_application(self) -> "FastMCP[typing.Any]": return self.bootstrap_config.application ``` -`_build_teardown_lifespan(teardown_callable)` is a module-level helper returning an `@asynccontextmanager` that yields `{}` on enter and calls `teardown_callable()` on exit. Combined with the user's existing lifespan via `combine_lifespans` so user setup/teardown still runs. +No lifespan wiring (see §"Teardown is manual" below). The bootstrapper relies entirely on the base class's instrument lifecycle; users are responsible for calling `bootstrapper.teardown()` themselves. + +### Teardown is manual + +Empirically verified during implementation: `FastMCP.lifespan` is a bound method on the `AggregateProvider` mixin (not a settable attribute), and the actual runtime hook is the private `_lifespan` attribute set at constructor time. Setting `app.lifespan = ...` succeeds (it shadows the bound method on the instance) but has zero runtime effect — FastMCP's transport runners read `_lifespan` directly. -### Teardown wiring risk +Resolution paths considered: -This design assumes `FastMCP.lifespan` is a settable attribute on a constructed instance. Evidence from the FastMCP docs: `FastMCP(lifespan=...)` is the documented constructor arg, and `combine_lifespans` is the documented stacking helper, but post-construction mutation isn't explicitly documented either way. If FastMCP makes `lifespan` read-only in a future release, the assignment raises `AttributeError` at bootstrap time — loud and obvious. Mitigation: +- **Mutate `app._lifespan` directly** — works today, but reaches into private API. Brittle across FastMCP releases. +- **Rebuild the user's `FastMCP` instance with a composed `lifespan=...`** — intrusive; copying every constructor arg the user may have set is impractical and would break the "user owns the FastMCP" contract. +- **No automatic teardown wiring** — matches microbootstrap PR141's posture. Users who need teardown call `bootstrapper.teardown()` from their own `lifespan=` callable, an ASGI shutdown handler, or `atexit`. **Adopted.** -- The test `test_fastmcp_teardown_runs_via_asgi_lifespan` exercises the full ASGI startup → shutdown cycle and asserts `bootstrapper.is_bootstrapped` flips back to `False`. Any FastMCP-side regression breaks this test immediately. -- If the read-only future ever materializes, the fallback is to wrap the application in a "lifespan-injecting" factory at `_prepare_application` time, or to document that teardown is manual (microbootstrap PR141's posture). The spec does not pre-build that fallback — YAGNI. +This is the documented fallback from the original "Teardown wiring risk" section. The bootstrapper's `teardown()` method remains fully functional; it's just not wired automatically. The integrations page must document this clearly. --- @@ -194,7 +195,6 @@ Top-level conditional imports inside `lite_bootstrap/bootstrappers/fastmcp_boots if import_checker.is_fastmcp_installed: from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext - from fastmcp.utilities.lifespan import combine_lifespans from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -224,9 +224,8 @@ Async tests rely on the project's existing `asyncio_mode = "auto"` pytest-asynci 8. **`test_fastmcp_logging_middleware_disabled_via_flag`** — `logging_turn_off_middleware=True`, assert no `FastMcpLoggingMiddleware` in `application.middleware`. 9. **`test_fastmcp_logging_middleware_logs_method_source_type_and_duration`** — drive `on_message` directly with a hand-built `MiddlewareContext` and `monkeypatch.setattr` the `fastmcp_access_logger`; assert `info(...)` called once with `method="tools/list"`, `mcp={"method": ..., "source": ..., "type": ...}`, and an integer `duration`. 10. **`test_fastmcp_logging_middleware_logs_exception_on_failure`** — `call_next` raises a custom exception; assert `exception(...)` called once and the exception propagates. -11. **`test_fastmcp_teardown_runs_via_asgi_lifespan`** — boot, drive the ASGI `lifespan` startup+shutdown of `application.http_app()` via the lifespan protocol on `httpx.AsyncClient`; assert `bootstrapper.is_bootstrapped is False` after shutdown. -12. **`test_fastmcp_existing_user_lifespan_is_preserved`** — user passes `application=FastMCP(lifespan=user_lifespan)` where `user_lifespan` flips a sentinel; assert both that the sentinel flips AND `bootstrapper.is_bootstrapped` is False after shutdown. -13. **`test_fastmcp_bootstrapper_not_ready_when_fastmcp_missing`** — `monkeypatch.setattr(import_checker, "is_fastmcp_installed", False)`, assert `BootstrapperNotReadyError` raised with `"fastmcp is not installed"`. +11. **`test_fastmcp_teardown_resets_is_bootstrapped`** — boot, call `bootstrapper.teardown()` directly, assert `bootstrapper.is_bootstrapped is False`. (Replaces the ASGI-lifespan test; teardown is no longer wired through `FastMCP.lifespan`.) +12. **`test_fastmcp_bootstrapper_not_ready_when_fastmcp_missing`** — `monkeypatch.setattr(import_checker, "is_fastmcp_installed", False)`, assert `BootstrapperNotReadyError` raised with `"fastmcp is not installed"`. Tests 9 and 10 hand-build `MiddlewareContext` instances and monkeypatch the module-level logger — this matches the test style microbootstrap PR141 uses (`tests/middlewares/test_fastmcp.py`). From 11d49e5a659afedaf8cc2d1df67aee5f6a42f572 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:38:24 +0300 Subject: [PATCH 13/19] docs: document FastMcpBootstrapper integration --- CLAUDE.md | 1 + README.md | 1 + docs/index.md | 1 + docs/integrations/fastmcp.md | 84 +++++++++++++++++++++++++++++++ docs/introduction/installation.md | 24 +++++---- 5 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 docs/integrations/fastmcp.md diff --git a/CLAUDE.md b/CLAUDE.md index 36b7e86..56ded7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ BaseBootstrapper (abc.ABC) ├── FastAPIBootstrapper ├── LitestarBootstrapper ├── FastStreamBootstrapper + ├── FastMcpBootstrapper └── FreeBootstrapper ``` diff --git a/README.md b/README.md index 9e35afe..1ddeae5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Those instruments can be bootstrapped for: - [LiteStar](https://lite-bootstrap.readthedocs.io/integrations/litestar) - [FastStream](https://lite-bootstrap.readthedocs.io/integrations/faststream) - [FastAPI](https://lite-bootstrap.readthedocs.io/integrations/fastapi) +- [FastMCP](https://lite-bootstrap.readthedocs.io/integrations/fastmcp) - [services and scripts without frameworks](https://lite-bootstrap.readthedocs.io/integrations/free) --- diff --git a/docs/index.md b/docs/index.md index c1304db..df76f71 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,5 +19,6 @@ Those instruments can be bootstrapped for: - [LiteStar](integrations/litestar) - [FastStream](integrations/faststream) - [FastAPI](integrations/fastapi) +- [FastMCP](integrations/fastmcp) - [services and scripts without frameworks](integrations/free) --- diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md new file mode 100644 index 0000000..5825ba2 --- /dev/null +++ b/docs/integrations/fastmcp.md @@ -0,0 +1,84 @@ +# Usage with `FastMCP` + +## 1. Install `lite-bootstrap` with the FastMCP extras and any instruments you want: + +`lite-bootstrap` does not ship a `fastmcp-all` rollup extra — compose the extras +you need explicitly. + +=== "uv" + + ```bash + uv add 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' + ``` + +=== "pip" + + ```bash + pip install 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' + ``` + +=== "poetry" + + ```bash + poetry add 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' + ``` + +Read more about available extras [here](../../../introduction/installation): + +## 2. Define bootstrapper config and build your application: + +```python +from fastmcp import FastMCP +from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig + + +bootstrapper_config = FastMcpConfig( + service_name="microservice", + service_version="2.0.0", + service_environment="test", + sentry_dsn="https://testdsn@localhost/1", + prometheus_metrics_path="/custom-metrics/", + health_checks_path="/custom-health/", + logging_buffer_capacity=0, +) +bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config) +application: FastMCP = bootstrapper.bootstrap() + + +@application.tool +def greet_person(person_name: str) -> str: + return f"Hello, {person_name}!" +``` + +Set `logging_turn_off_middleware=True` on the config to disable the per-MCP-message +access log middleware. Set `health_checks_enabled=False` to omit the health route. + +## 3. Teardown + +`FastMcpBootstrapper` does not wire teardown automatically (FastMCP captures its +lifespan at construction time only). Call `bootstrapper.teardown()` yourself +during shutdown — typically from a `lifespan=` callable you pass to `FastMCP`, +from an ASGI shutdown handler, or via `atexit`: + +```python +import contextlib +from fastmcp import FastMCP + + +@contextlib.asynccontextmanager +async def lifespan(app: FastMCP): + try: + yield + finally: + bootstrapper.teardown() + + +bootstrapper_config = FastMcpConfig( + service_name="microservice", + application=FastMCP(lifespan=lifespan), +) +bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config) +application = bootstrapper.bootstrap() +``` + +Read more about available configuration options [here](../../../introduction/configuration): diff --git a/docs/introduction/installation.md b/docs/introduction/installation.md index 5874511..21e402a 100644 --- a/docs/introduction/installation.md +++ b/docs/introduction/installation.md @@ -4,17 +4,19 @@ You can choose required framework and instruments using this table: -| Instrument | Litestar | Faststream | FastAPI | Free Bootstrapper, without framework | -|---------------|--------------------|----------------------|-------------------|--------------------------------------| -| sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` | -| prometheus | `litestar-metrics` | `faststream-metrics` | `fastapi-metrics` | not used | -| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | `otl` | -| pyroscope | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | -| structlog | `litestar-logging` | `faststream-logging` | `fastapi-logging` | `logging` | -| cors | no extra | not used | no extra | not used | -| swagger | no extra | not used | no extra | not used | -| health-checks | no extra | no extra | no extra | not used | -| all | `litestar-all` | `faststream-all` | `fastapi-all` | `free-all` | +FastMCP has no per-pair (`fastmcp-sentry`, …) or rollup (`fastmcp-all`) extras because they would not pull in new dependencies. Compose what you need yourself, e.g. `lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]`. + +| Instrument | Litestar | Faststream | FastAPI | FastMCP | Free Bootstrapper, without framework | +|---------------|--------------------|----------------------|-------------------|---------------------|--------------------------------------| +| sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` (compose) | `sentry` | +| prometheus | `litestar-metrics` | `faststream-metrics` | `fastapi-metrics` | `fastmcp-metrics` | not used | +| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | not used | `otl` | +| pyroscope | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | +| structlog | `litestar-logging` | `faststream-logging` | `fastapi-logging` | `logging` (compose) | `logging` | +| cors | no extra | not used | no extra | not used | not used | +| swagger | no extra | not used | no extra | not used | not used | +| health-checks | no extra | no extra | no extra | no extra | not used | +| all | `litestar-all` | `faststream-all` | `fastapi-all` | no rollup (compose) | `free-all` | * not used - means that the instrument is not implemented in the integration. * no extra - means that the instrument requires no additional dependencies. From f6e61d6cd3005bdb04d5b031c71a2974b0fbe06e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 23:46:56 +0300 Subject: [PATCH 14/19] test: tighten FastMcpBootstrapper not-ready check to BootstrapperNotReadyError Final-review followup: assert on the project's typed exception rather than bare RuntimeError so a future change to the base class hierarchy doesn't silently weaken the test. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_fastmcp_bootstrap.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 5cb8443..a01f4e5 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -8,7 +8,7 @@ from starlette import status from starlette.testclient import TestClient -from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig +from lite_bootstrap import BootstrapperNotReadyError, FastMcpBootstrapper, FastMcpConfig from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload @@ -27,7 +27,10 @@ def test_fastmcp_bootstrap_returns_same_application() -> None: def test_fastmcp_bootstrapper_not_ready() -> None: - with emulate_package_missing("fastmcp"), pytest.raises(RuntimeError, match="fastmcp is not installed"): + with ( + emulate_package_missing("fastmcp"), + pytest.raises(BootstrapperNotReadyError, match="fastmcp is not installed"), + ): FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) From b7867811f684e3ebc2c8d276e1ac81fa34d7eb47 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 00:03:19 +0300 Subject: [PATCH 15/19] feat: wire FastMcpBootstrapper teardown via FastMCP Provider.lifespan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier work concluded teardown had to be manual because FastMCP.lifespan is a read-only bound method. Re-investigation surfaced FastMCP.add_provider, which DOES accept post-construction providers and invokes each provider's lifespan async context manager during ASGI startup/shutdown. A small _TeardownProvider(Provider) wraps bootstrapper.teardown() and is registered automatically from FastMcpBootstrapper.__init__. Users no longer need to call teardown() manually — it runs when the ASGI application that serves application.http_app() shuts down. Restores test_fastmcp_teardown_runs_via_asgi_lifespan with a hand-rolled ASGI lifespan driver. Drops the "manual teardown" section from the integrations doc. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/integrations/fastmcp.md | 41 ++++--------------- .../bootstrappers/fastmcp_bootstrapper.py | 18 ++++++++ tests/test_fastmcp_bootstrap.py | 35 +++++++++++++--- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 5825ba2..9d90b3a 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -1,26 +1,23 @@ # Usage with `FastMCP` -## 1. Install `lite-bootstrap` with the FastMCP extras and any instruments you want: - -`lite-bootstrap` does not ship a `fastmcp-all` rollup extra — compose the extras -you need explicitly. +## 1. Install `lite-bootstrap[fastmcp-all]`: === "uv" ```bash - uv add 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' + uv add lite-bootstrap[fastmcp-all] ``` === "pip" ```bash - pip install 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' + pip install lite-bootstrap[fastmcp-all] ``` === "poetry" ```bash - poetry add 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' + poetry add lite-bootstrap[fastmcp-all] ``` Read more about available extras [here](../../../introduction/installation): @@ -53,32 +50,8 @@ def greet_person(person_name: str) -> str: Set `logging_turn_off_middleware=True` on the config to disable the per-MCP-message access log middleware. Set `health_checks_enabled=False` to omit the health route. -## 3. Teardown - -`FastMcpBootstrapper` does not wire teardown automatically (FastMCP captures its -lifespan at construction time only). Call `bootstrapper.teardown()` yourself -during shutdown — typically from a `lifespan=` callable you pass to `FastMCP`, -from an ASGI shutdown handler, or via `atexit`: - -```python -import contextlib -from fastmcp import FastMCP - - -@contextlib.asynccontextmanager -async def lifespan(app: FastMCP): - try: - yield - finally: - bootstrapper.teardown() - - -bootstrapper_config = FastMcpConfig( - service_name="microservice", - application=FastMCP(lifespan=lifespan), -) -bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config) -application = bootstrapper.bootstrap() -``` +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). Read more about available configuration options [here](../../../introduction/configuration): diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index c87c57d..c85448a 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -1,3 +1,4 @@ +import contextlib import dataclasses import time import typing @@ -14,6 +15,7 @@ if import_checker.is_fastmcp_installed: from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext + from fastmcp.server.providers import Provider from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -32,6 +34,18 @@ def _make_fastmcp() -> "FastMCP[typing.Any]": if import_checker.is_fastmcp_installed: + class _TeardownProvider(Provider): + def __init__(self, teardown: typing.Callable[[], None]) -> None: + super().__init__() + self._teardown = teardown + + @contextlib.asynccontextmanager + async def lifespan(self) -> typing.AsyncIterator[None]: + try: + yield + finally: + self._teardown() + class FastMcpLoggingMiddleware(Middleware): async def on_message( self, @@ -135,5 +149,9 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): def is_ready(self) -> bool: return import_checker.is_fastmcp_installed + def __init__(self, bootstrap_config: FastMcpConfig) -> None: + super().__init__(bootstrap_config) + self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown)) + 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 a01f4e5..7a8c991 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -1,4 +1,5 @@ import typing +import uuid from unittest.mock import MagicMock import prometheus_client @@ -42,6 +43,32 @@ def test_fastmcp_teardown_resets_is_bootstrapped() -> None: assert bootstrapper.is_bootstrapped is False +async def _drive_asgi_lifespan(application: typing.Any) -> list[dict[str, typing.Any]]: # noqa: ANN401 + inbox = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}] + outbox: list[dict[str, typing.Any]] = [] + + async def receive() -> dict[str, typing.Any]: + return inbox.pop(0) + + async def send(message: dict[str, typing.Any]) -> None: + outbox.append(message) + + await application({"type": "lifespan", "asgi": {"version": "3.0"}}, receive, send) + return outbox + + +async def test_fastmcp_teardown_runs_via_asgi_lifespan() -> None: + bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + application = bootstrapper.bootstrap() + assert bootstrapper.is_bootstrapped + + sent = await _drive_asgi_lifespan(application.http_app()) + + assert any(msg["type"] == "lifespan.startup.complete" for msg in sent) + assert any(msg["type"] == "lifespan.shutdown.complete" for msg in sent) + assert not bootstrapper.is_bootstrapped + + async def test_fastmcp_logging_middleware_logs_success(monkeypatch: pytest.MonkeyPatch) -> None: fake_logger = MagicMock() monkeypatch.setattr( @@ -157,12 +184,8 @@ def test_fastmcp_health_check_disabled_when_flag_false() -> None: def test_fastmcp_prometheus_route_exposes_registered_metric() -> None: - counter_name = "fastmcp_plan_test_requests_total" - try: - counter = prometheus_client.Counter(counter_name, "FastMCP plan test counter.") - except ValueError: - collector = prometheus_client.REGISTRY._names_to_collectors[counter_name] # noqa: SLF001 - counter = typing.cast(prometheus_client.Counter, collector) + counter_name = f"fastmcp_plan_test_requests_{uuid.uuid4().hex}_total" + counter = prometheus_client.Counter(counter_name, "FastMCP plan test counter.") counter.inc() config = _make_test_config() From bc7e28740f801cba6a8e0319c5473b2ae51c0120 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 00:03:22 +0300 Subject: [PATCH 16/19] feat: add fastmcp-all rollup extra Matches the fastapi-all / litestar-all / faststream-all naming pattern. fastmcp-all = [lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]], so users can install the whole stack with a single extra rather than composing five extras by hand. Updates docs/introduction/installation.md to list `fastmcp-all` in the "all" row and drops the now-obsolete "no rollup" note above the table. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/introduction/installation.md | 4 +--- pyproject.toml | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/introduction/installation.md b/docs/introduction/installation.md index 21e402a..1e283d5 100644 --- a/docs/introduction/installation.md +++ b/docs/introduction/installation.md @@ -4,8 +4,6 @@ You can choose required framework and instruments using this table: -FastMCP has no per-pair (`fastmcp-sentry`, …) or rollup (`fastmcp-all`) extras because they would not pull in new dependencies. Compose what you need yourself, e.g. `lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]`. - | Instrument | Litestar | Faststream | FastAPI | FastMCP | Free Bootstrapper, without framework | |---------------|--------------------|----------------------|-------------------|---------------------|--------------------------------------| | sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` (compose) | `sentry` | @@ -16,7 +14,7 @@ FastMCP has no per-pair (`fastmcp-sentry`, …) or rollup (`fastmcp-all`) extras | cors | no extra | not used | no extra | not used | not used | | swagger | no extra | not used | no extra | not used | not used | | health-checks | no extra | no extra | no extra | no extra | not used | -| all | `litestar-all` | `faststream-all` | `fastapi-all` | no rollup (compose) | `free-all` | +| all | `litestar-all` | `faststream-all` | `fastapi-all` | `fastmcp-all` | `free-all` | * not used - means that the instrument is not implemented in the integration. * no extra - means that the instrument requires no additional dependencies. diff --git a/pyproject.toml b/pyproject.toml index d2bf73b..a55b725 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,9 @@ fastmcp-metrics = [ "lite-bootstrap[fastmcp]", "prometheus-client>=0.20", ] +fastmcp-all = [ + "lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]", +] [dependency-groups] dev = [ From 7a1020161740f4bebf4179bdeba6509890debe02 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 00:03:27 +0300 Subject: [PATCH 17/19] fix: stop passing None broker positionally to AsgiFastStream in tests A recent faststream release tightened AsgiFastStream's typed signature to BrokerUsecase (no None). The wo_broker test path passed broker=None positionally, which started failing CI with both a ty error and a runtime AttributeError on _update_fd_config. build_faststream_config now omits the broker argument entirely when no broker is provided. test_faststream_bootstrap_health_check_wo_broker keeps its semantics (verifying the brokerless health-check path). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_faststream_bootstrap.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index b890db9..e81edec 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -34,6 +34,15 @@ def broker() -> RedisBroker: def build_faststream_config( broker: BrokerUsecase[typing.Any, typing.Any] | None = None, ) -> FastStreamConfig: + asgi_kwargs: dict[str, typing.Any] = { + "asyncapi_path": faststream.asgi.AsyncAPIRoute("/docs/"), + "specification": faststream.AsyncAPI(), + } + application = ( + faststream.asgi.AsgiFastStream(broker, **asgi_kwargs) + if broker is not None + else faststream.asgi.AsgiFastStream(**asgi_kwargs) + ) return FastStreamConfig( service_name="microservice", service_version="2.0.0", @@ -48,11 +57,7 @@ def build_faststream_config( sentry_additional_params={"transport": SentryTestTransport()}, health_checks_path="/custom-health/", logging_buffer_capacity=0, - application=faststream.asgi.AsgiFastStream( - broker, - asyncapi_path=faststream.asgi.AsyncAPIRoute("/docs/"), - specification=faststream.AsyncAPI(), - ), + application=application, ) From 5a85a19e2e8e3d6918a376a1f27beea1db791994 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 00:06:50 +0300 Subject: [PATCH 18/19] refactor: drop redundant is_structlog_installed guard in FastMcpLoggingInstrument LoggingInstrument.check_dependencies (the parent) already returns is_structlog_installed, and BaseBootstrapper._register_or_skip filters the instrument out before instantiation when it returns False. Reaching FastMcpLoggingInstrument.bootstrap() therefore implies structlog is installed; the inner guard is unreachable defensive code. Surfaced by codecov flagging it as uncovered on PR #105. Co-Authored-By: Claude Opus 4.7 (1M context) --- lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index c85448a..89322c0 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -128,8 +128,6 @@ def bootstrap(self) -> None: super().bootstrap() if self.bootstrap_config.logging_turn_off_middleware: return - if not import_checker.is_structlog_installed: - return self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) From 0bfbc8fdb48209011cd0420cdf5f8c5efdf27638 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 00:26:47 +0300 Subject: [PATCH 19/19] docs: sync fastmcp spec/plan with Provider.lifespan teardown, comment _TeardownProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the spec/plan sections still described the short-lived "manual teardown" fallback that was reverted in commit b786781. Specifically: - spec §"Teardown is manual" → §"Teardown via Provider.lifespan" - spec Tests list: restore test_fastmcp_teardown_runs_via_asgi_lifespan - plan locked-decisions: replace "Teardown: manual" with the add_provider wiring; mention fastmcp-all rollup - plan Task 5: explain the direct + lifespan test pair Also adds a one-line comment to _TeardownProvider explaining that FastMCP exposes no on_shutdown-style API and Provider.lifespan is the only public post-construction hook. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-06-01-fastmcp-bootstrapper.md | 8 ++--- .../2026-06-01-fastmcp-bootstrapper-design.md | 32 ++++++++++++------- .../bootstrappers/fastmcp_bootstrapper.py | 2 ++ 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md index 1d2e9b2..3c7c5ac 100644 --- a/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md +++ b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md @@ -35,8 +35,8 @@ Two new files, six modified files. - **Instrument set:** Sentry, Pyroscope, structlog logging + MCP middleware, health, prometheus. No OTel/CORS/Swagger. - **Middleware default:** mounted on; `logging_turn_off_middleware: bool = False` flag to opt out. - **Prometheus route:** `application.custom_route` at `prometheus_metrics_path`, always-on (no per-bootstrapper opt-out flag). -- **Teardown:** **manual** (user calls `bootstrapper.teardown()` themselves). Originally planned to wrap `FastMCP.lifespan` via `combine_lifespans`, but discovered empirically that `app.lifespan` is a read-only bound method and the real hook is `app._lifespan` (private). Documented fallback adopted; spec §"Teardown is manual" updated. -- **Extras:** only `fastmcp` and `fastmcp-metrics`. No `fastmcp-sentry` / `fastmcp-logging` / `fastmcp-all` because they would not pull in a new direct dependency. +- **Teardown:** wired automatically via `FastMCP.add_provider(_TeardownProvider(self.teardown))` in `FastMcpBootstrapper.__init__`. `_TeardownProvider` is an empty `Provider` subclass whose `async def lifespan(self)` calls the teardown callable on exit. This is the only **public, post-construction** lifecycle hook FastMCP exposes — `FastMCP.lifespan` is a read-only bound method and `_lifespan` is private. Two earlier approaches considered and rejected: wrapping `FastMCP.lifespan` via `combine_lifespans` (impossible — `lifespan` is read-only) and manual teardown (briefly adopted then reverted when `add_provider` was identified). See spec §"Teardown via Provider.lifespan". +- **Extras:** `fastmcp`, `fastmcp-metrics`, and `fastmcp-all` rollup (matches `fastapi-all` / `litestar-all` / `faststream-all`). No `fastmcp-sentry` / `fastmcp-logging` because per-pair composites add no new direct dependencies. - **Default config app:** `default_factory=_make_fastmcp` where `_make_fastmcp()` returns `FastMCP()`. No `UnsetType` sentinel — `FastMCP()` needs no derived config. - **Middleware default registry:** `prometheus_client.REGISTRY`. No fastmcp-specific registry config field. @@ -320,9 +320,9 @@ git commit -m "feat: scaffold FastMcpBootstrapper and FastMcpConfig" --- -## Task 5: Verify manual teardown resets is_bootstrapped +## Task 5: Verify teardown resets is_bootstrapped (direct + via ASGI lifespan) -Originally planned as an ASGI-lifespan replay test, but the spec's "Teardown wiring risk" materialized — see the locked-decisions section. Teardown is manual. This task simply adds a regression test confirming `bootstrapper.teardown()` toggles `is_bootstrapped` correctly. (The existing `test_fastmcp_bootstrap_returns_same_application` test already calls `teardown()` at the end; this task makes the assertion explicit.) +Initially this task covered only the direct-call assertion because the design first concluded teardown had to be manual (the `FastMCP.lifespan` read-only finding). Once `add_provider` + `Provider.lifespan` was identified as the right post-construction hook, the ASGI-lifespan replay test was restored alongside the direct test. Both land in the same task. (The earlier `test_fastmcp_bootstrap_returns_same_application` test calls `teardown()` at the end too; this task makes the assertion explicit and adds the lifespan-driven counterpart.) **Files:** - Modify: `tests/test_fastmcp_bootstrap.py` diff --git a/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md b/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md index 0e67117..b9bbb9d 100644 --- a/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md +++ b/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md @@ -12,8 +12,9 @@ This is a design spec, not an implementation plan. Per-PR sequencing and task-by - Match the behavior shipped by microbootstrap PR141: Sentry, Pyroscope, structlog-based logging (with an MCP-aware access middleware), an HTTP health endpoint, and an HTTP Prometheus endpoint. - Match the lite-bootstrap architectural conventions (frozen-dataclass configs composed via multiple inheritance, framework-subclass instruments, `instruments_types` ClassVar, optional-import guards via `import_checker`). +- Improve over PR141 by wiring `teardown()` through FastMCP's `Provider.lifespan` hook so it runs automatically on ASGI shutdown. -> **Update (2026-06-01, during execution):** the design originally aimed to *improve* over PR141 by wiring `teardown()` through the FastMCP lifespan. Implementation discovered that `FastMCP.lifespan` is a read-only bound method and the runtime hook is the private `_lifespan` attribute (the lifespan is captured at constructor time via the `lifespan=` kwarg). Post-construction wiring would require either touching `_lifespan` (private API, fragile) or rebuilding the user's `FastMCP` instance (intrusive). The risk documented under §"Teardown wiring risk" materialized; per the documented fallback, teardown is now manual — matching microbootstrap PR141's posture. See the updated §"Bootstrapper" and §"Tests" sections below. +> **Execution note (2026-06-01):** the design first tried to wrap `FastMCP.lifespan` via `combine_lifespans`. Implementation discovered `FastMCP.lifespan` is a read-only bound method and the runtime hook (`_lifespan`) is captured at constructor time only, so post-construction wrapping is impossible. We briefly fell back to "manual teardown" (matching microbootstrap PR141) but then identified `FastMCP.add_provider()` + `Provider.lifespan` as the correct post-construction hook — public, documented, invoked during ASGI startup/shutdown. Adopted; see §"Bootstrapper" and §"Tests" below. The PR's final code uses this approach. ## Non-goals @@ -81,7 +82,7 @@ Instrument order (Pyroscope → Sentry → Health → Logging → Prometheus) mi - **Custom HTTP routes**: `@application.custom_route(path, methods=["GET"], name=..., include_in_schema=...)` registers Starlette-style handlers that surface on `application.http_app()`. Custom routes bypass `AuthProvider`, which is correct behavior for health and metrics endpoints. - **MCP protocol middleware**: `application.add_middleware(middleware_instance)` registers a FastMCP `Middleware` subclass that runs on every MCP message (tools/list, tool/call, resources/list, etc.). This is distinct from Starlette HTTP middleware passed via `application.http_app(middleware=[...])`. We use the former because we want per-method MCP-level access logs. -- **Lifespan (not used)**: `FastMCP(lifespan=...)` accepts an `@asynccontextmanager` callable at construction time only. The post-construction `app.lifespan` attribute is a read-only bound method, and the actual runtime hook is `app._lifespan` (private). The bootstrapper therefore does **not** wire teardown via lifespan composition. Users who need deterministic shutdown call `bootstrapper.teardown()` themselves (e.g., in their own `lifespan=` callable, an ASGI shutdown handler, or `atexit`). +- **Lifecycle hook — `Provider.lifespan`**: FastMCP exposes no `on_shutdown`-style API. `FastMCP(lifespan=...)` is constructor-only (the runtime hook is the private `_lifespan` attribute, captured at construction time). The only **public, post-construction** hook is `app.add_provider(provider)`: each registered provider's `async def lifespan(self)` runs as an async context manager during the server's ASGI lifespan startup/shutdown. The bootstrapper registers an empty internal `_TeardownProvider` whose `lifespan` calls `self.teardown()` on exit. Documented in FastMCP under "Custom Provider — Lifecycle Management". --- @@ -167,23 +168,29 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): def is_ready(self) -> bool: return import_checker.is_fastmcp_installed + def __init__(self, bootstrap_config: FastMcpConfig) -> None: + super().__init__(bootstrap_config) + self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown)) + def _prepare_application(self) -> "FastMCP[typing.Any]": return self.bootstrap_config.application ``` -No lifespan wiring (see §"Teardown is manual" below). The bootstrapper relies entirely on the base class's instrument lifecycle; users are responsible for calling `bootstrapper.teardown()` themselves. +`_TeardownProvider` is a module-private `Provider` subclass whose `lifespan` async-cm wraps a `try/yield/finally` that calls the supplied teardown callable on exit. Registered automatically from `__init__` so users don't need to call `bootstrap_config.application.add_provider(...)` themselves. + +### Teardown via Provider.lifespan -### Teardown is manual +`FastMCP.lifespan` is a bound method on the `AggregateProvider` mixin (not a settable attribute), and the actual runtime hook is the private `_lifespan` attribute set at constructor time only. Setting `app.lifespan = ...` succeeds but has zero runtime effect — FastMCP's transport runners read `_lifespan` directly. -Empirically verified during implementation: `FastMCP.lifespan` is a bound method on the `AggregateProvider` mixin (not a settable attribute), and the actual runtime hook is the private `_lifespan` attribute set at constructor time. Setting `app.lifespan = ...` succeeds (it shadows the bound method on the instance) but has zero runtime effect — FastMCP's transport runners read `_lifespan` directly. +The public alternative: `app.add_provider(provider)` accepts a provider post-construction, and FastMCP invokes each registered provider's `async def lifespan(self)` as part of the server's ASGI lifespan startup/shutdown sequence. Verified empirically: registering a `Provider` after construction and driving the ASGI lifespan startup → shutdown does execute the provider's `lifespan` exit branch. -Resolution paths considered: +This gives us a documented, public hook with the right semantics. Trade-off: `Provider` is FastMCP's general extension abstraction (for tools/resources/prompts) and using it solely for a shutdown callback is semantically thin — a one-line comment in the source notes that this is the only public post-construction hook for the purpose. -- **Mutate `app._lifespan` directly** — works today, but reaches into private API. Brittle across FastMCP releases. -- **Rebuild the user's `FastMCP` instance with a composed `lifespan=...`** — intrusive; copying every constructor arg the user may have set is impractical and would break the "user owns the FastMCP" contract. -- **No automatic teardown wiring** — matches microbootstrap PR141's posture. Users who need teardown call `bootstrapper.teardown()` from their own `lifespan=` callable, an ASGI shutdown handler, or `atexit`. **Adopted.** +Resolution paths considered and rejected: -This is the documented fallback from the original "Teardown wiring risk" section. The bootstrapper's `teardown()` method remains fully functional; it's just not wired automatically. The integrations page must document this clearly. +- **Mutate `app._lifespan` directly** — works today, but reaches into private API. +- **Rebuild the user's `FastMCP` with a composed `lifespan=`** — intrusive; breaks the "user owns the FastMCP" contract. +- **No automatic wiring (manual teardown)** — adopted briefly, then reverted when `add_provider` was identified. --- @@ -224,8 +231,9 @@ Async tests rely on the project's existing `asyncio_mode = "auto"` pytest-asynci 8. **`test_fastmcp_logging_middleware_disabled_via_flag`** — `logging_turn_off_middleware=True`, assert no `FastMcpLoggingMiddleware` in `application.middleware`. 9. **`test_fastmcp_logging_middleware_logs_method_source_type_and_duration`** — drive `on_message` directly with a hand-built `MiddlewareContext` and `monkeypatch.setattr` the `fastmcp_access_logger`; assert `info(...)` called once with `method="tools/list"`, `mcp={"method": ..., "source": ..., "type": ...}`, and an integer `duration`. 10. **`test_fastmcp_logging_middleware_logs_exception_on_failure`** — `call_next` raises a custom exception; assert `exception(...)` called once and the exception propagates. -11. **`test_fastmcp_teardown_resets_is_bootstrapped`** — boot, call `bootstrapper.teardown()` directly, assert `bootstrapper.is_bootstrapped is False`. (Replaces the ASGI-lifespan test; teardown is no longer wired through `FastMCP.lifespan`.) -12. **`test_fastmcp_bootstrapper_not_ready_when_fastmcp_missing`** — `monkeypatch.setattr(import_checker, "is_fastmcp_installed", False)`, assert `BootstrapperNotReadyError` raised with `"fastmcp is not installed"`. +11. **`test_fastmcp_teardown_resets_is_bootstrapped`** — boot, call `bootstrapper.teardown()` directly, assert `bootstrapper.is_bootstrapped is False`. +12. **`test_fastmcp_teardown_runs_via_asgi_lifespan`** — boot, drive the ASGI lifespan startup → shutdown of `application.http_app()` via a hand-rolled ASGI driver, assert `bootstrapper.is_bootstrapped is False` after shutdown (proves the `_TeardownProvider` registration runs through FastMCP's provider lifecycle). +13. **`test_fastmcp_bootstrapper_not_ready_when_fastmcp_missing`** — `emulate_package_missing("fastmcp")`, assert `BootstrapperNotReadyError` raised with `"fastmcp is not installed"`. Tests 9 and 10 hand-build `MiddlewareContext` instances and monkeypatch the module-level logger — this matches the test style microbootstrap PR141 uses (`tests/middlewares/test_fastmcp.py`). diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 89322c0..2fed48d 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -35,6 +35,8 @@ def _make_fastmcp() -> "FastMCP[typing.Any]": 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