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..9d90b3a --- /dev/null +++ b/docs/integrations/fastmcp.md @@ -0,0 +1,57 @@ +# Usage with `FastMCP` + +## 1. Install `lite-bootstrap[fastmcp-all]`: + +=== "uv" + + ```bash + uv add lite-bootstrap[fastmcp-all] + ``` + +=== "pip" + + ```bash + pip install lite-bootstrap[fastmcp-all] + ``` + +=== "poetry" + + ```bash + poetry add lite-bootstrap[fastmcp-all] + ``` + +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. + +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/docs/introduction/installation.md b/docs/introduction/installation.md index 5874511..1e283d5 100644 --- a/docs/introduction/installation.md +++ b/docs/introduction/installation.md @@ -4,17 +4,17 @@ 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` | +| 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` | `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/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md index 7276ccf..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:** wrap `FastMCP.lifespan` via `combine_lifespans`. Risk: assumes mutability of `FastMCP.lifespan` — guarded by the lifespan-replay test in Task 10. -- **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. @@ -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 teardown resets is_bootstrapped (direct + via ASGI lifespan) -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. +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` -### 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..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,7 +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 the FastMCP lifespan rather than relying on the user to call it manually. +- Improve over PR141 by wiring `teardown()` through FastMCP's `Provider.lifespan` hook so it runs automatically on ASGI shutdown. + +> **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 @@ -80,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**: `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. +- **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". --- @@ -168,21 +170,27 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): 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)) + self.bootstrap_config.application.add_provider(_TeardownProvider(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. +`_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 + +`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. + +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. -### Teardown wiring risk +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. -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: +Resolution paths considered and rejected: -- 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. +- **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. --- @@ -194,7 +202,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 +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_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`. +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/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. 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..2fed48d --- /dev/null +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -0,0 +1,157 @@ +import contextlib +import dataclasses +import time +import typing + +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, LoggingInstrument +from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument +from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument +from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument + + +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 + +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() + + +if import_checker.is_fastmcp_installed: + + class _TeardownProvider(Provider): + # FastMCP exposes no on_shutdown-style API; Provider.lifespan is the only public + # post-construction hook whose async-cm runs during ASGI startup/shutdown. + def __init__(self, teardown: typing.Callable[[], None]) -> None: + super().__init__() + self._teardown = teardown + + @contextlib.asynccontextmanager + async def lifespan(self) -> typing.AsyncIterator[None]: + try: + yield + finally: + self._teardown() + + 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) + 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())) + + +@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}, + ) + + +@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 + 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 + not_ready_message = "fastmcp is not installed" + + 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/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 diff --git a/pyproject.toml b/pyproject.toml index b9431dc..a55b725 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,16 @@ 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", +] +fastmcp-all = [ + "lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]", +] [dependency-groups] dev = [ diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py new file mode 100644 index 0000000..7a8c991 --- /dev/null +++ b/tests/test_fastmcp_bootstrap.py @@ -0,0 +1,281 @@ +import typing +import uuid +from unittest.mock import MagicMock + +import prometheus_client +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 BootstrapperNotReadyError, FastMcpBootstrapper, FastMcpConfig +from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware +from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload + + +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(BootstrapperNotReadyError, 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 + + +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( + "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() + + +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() + + +def test_fastmcp_prometheus_route_exposes_registered_metric() -> None: + 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() + 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() + + +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() + + +@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() 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, )