diff --git a/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md new file mode 100644 index 0000000..7276ccf --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md @@ -0,0 +1,1187 @@ +# FastMCP Bootstrapper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a `FastMcpBootstrapper` and `FastMcpConfig` that wire the lite-bootstrap instrument stack (Sentry, Pyroscope, structlog logging with an MCP-aware access middleware, JSON health check, Prometheus metrics) onto a FastMCP server, with teardown plumbed through the FastMCP lifespan. + +**Architecture:** Single-file bootstrapper module mirroring `faststream_bootstrapper.py` (Approach A from brainstorming). Frozen dataclass config composed via multiple inheritance over the shared instrument configs (no OTel/CORS/Swagger). Framework-specific instrument subclasses. Teardown wired by replacing `FastMCP.lifespan` with `fastmcp.utilities.lifespan.combine_lifespans(existing, teardown_lifespan)`. Two new pyproject extras (`fastmcp`, `fastmcp-metrics`); no composite/rollup extras per the project rule. + +**Tech Stack:** Python 3.10+ dataclasses, `fastmcp`, `structlog`, `prometheus_client`, `starlette.responses`, `pytest-asyncio` (auto mode), `httpx2` test client, `unittest.mock.MagicMock` / `pytest.MonkeyPatch`. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md` +**Reference PR:** [microbootstrap PR #141](https://github.com/community-of-python/microbootstrap/pull/141). + +--- + +## File Structure + +Two new files, six modified files. + +- **Create:** `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` — `_make_fastmcp`, `_build_teardown_lifespan`, `FastMcpConfig`, `FastMcpLoggingMiddleware`, `FastMcpHealthChecksInstrument`, `FastMcpLoggingInstrument`, `FastMcpPrometheusInstrument`, `FastMcpBootstrapper`. +- **Create:** `tests/test_fastmcp_bootstrap.py` — full test suite (13 tests; see spec §Tests). +- **Create:** `docs/integrations/fastmcp.md` — integration usage page. +- **Modify:** `lite_bootstrap/import_checker.py` — add `is_fastmcp_installed`. +- **Modify:** `lite_bootstrap/__init__.py` — re-export `FastMcpBootstrapper`, `FastMcpConfig`. +- **Modify:** `pyproject.toml` — add extras, append `"fastmcp"` keyword. +- **Modify:** `README.md` — append FastMCP to framework list. +- **Modify:** `docs/index.md` — append FastMCP to framework list. +- **Modify:** `docs/introduction/installation.md` — new column in extras table + composition note. +- **Modify:** `CLAUDE.md` — `FastMcpBootstrapper` in Core-pattern tree. + +--- + +## Locked decisions (from spec) + +- **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. +- **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. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b feat/fastmcp-bootstrapper +``` + +Expected: `Switched to a new branch 'feat/fastmcp-bootstrapper'`. + +--- + +## Task 2: Add `is_fastmcp_installed` to import_checker + +**Files:** +- Modify: `lite_bootstrap/import_checker.py` + +- [ ] **Step 1: Add the spec check** + +Current file ends at line 19 with `is_pyroscope_installed`. Append a new line after it: + +```python +is_fastmcp_installed = find_spec("fastmcp") is not None +``` + +After the change the relevant tail of `lite_bootstrap/import_checker.py` reads: + +```python +is_pyroscope_installed = find_spec("pyroscope") is not None +is_fastmcp_installed = find_spec("fastmcp") is not None +``` + +- [ ] **Step 2: Quick smoke-import** + +Run: `uv run --no-sync python -c "from lite_bootstrap.import_checker import is_fastmcp_installed; print(is_fastmcp_installed)"` + +Expected: prints `False` (fastmcp is not yet installed — Task 3 installs it). No `ImportError`. + +- [ ] **Step 3: Commit** + +```bash +git add lite_bootstrap/import_checker.py +git commit -m "feat: detect fastmcp via import_checker" +``` + +--- + +## Task 3: Add fastmcp extras and install + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Append the `fastmcp` extra** + +In `pyproject.toml` under `[project.optional-dependencies]`, immediately after the `faststream-all` block (line 114–117 currently), add: + +```toml +fastmcp = [ + "fastmcp", +] +fastmcp-metrics = [ + "lite-bootstrap[fastmcp]", + "prometheus-client>=0.20", +] +``` + +Do NOT add `fastmcp-sentry`, `fastmcp-logging`, or `fastmcp-all` — composite extras that don't pull in a new dependency are excluded by the project rule. + +- [ ] **Step 2: Append the `"fastmcp"` keyword** + +In `pyproject.toml` line 10–21, the `keywords` list ends with `"structlog",`. Insert `"fastmcp",` before the closing bracket so it joins the existing list: + +```toml +keywords = [ + "python", + "microservice", + "bootstrap", + "opentelemetry", + "sentry", + "error-tracing", + "fastapi", + "litestar", + "faststream", + "structlog", + "fastmcp", +] +``` + +- [ ] **Step 3: Sync dependencies** + +Run: `just install` + +Expected: `uv lock --upgrade` updates `uv.lock` to include `fastmcp` (and its transitive deps: `starlette`, `mcp`, etc.). `uv sync --all-extras --frozen --group lint` installs them. No errors. + +- [ ] **Step 4: Verify fastmcp is importable** + +Run: `uv run --no-sync python -c "from fastmcp import FastMCP; print(FastMCP)"` + +Expected: prints `` (or similar — confirms install). + +Run: `uv run --no-sync python -c "from lite_bootstrap.import_checker import is_fastmcp_installed; print(is_fastmcp_installed)"` + +Expected: prints `True`. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "feat: add fastmcp and fastmcp-metrics extras" +``` + +--- + +## Task 4: Scaffold `fastmcp_bootstrapper.py` with config + module helpers + +**Files:** +- Create: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` +- Create: `tests/test_fastmcp_bootstrap.py` + +This task lands the module skeleton: imports, optional-import guards, `_make_fastmcp`, `_build_teardown_lifespan`, `FastMcpConfig`, and a minimal `FastMcpBootstrapper` with `instruments_types = []`. Tests at the end of this task: default-factory yields a `FastMCP`, `bootstrap()` returns the same `FastMCP`, "not ready when fastmcp missing" raises. + +### Step 1: Write the failing tests + +- [ ] Create `tests/test_fastmcp_bootstrap.py` with the following content: + +```python +import contextlib +import time +import typing + +import prometheus_client +import pytest +import structlog +from fastmcp import FastMCP +from fastmcp.server.middleware import Middleware, MiddlewareContext +from starlette import status +from starlette.testclient import TestClient + +from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig +from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware +from tests.conftest import emulate_package_missing + + +logger = structlog.getLogger(__name__) + + +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()) +``` + +### Step 2: Run tests to verify failure mode + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v` + +Expected: All three tests fail with `ImportError: cannot import name 'FastMcpBootstrapper' from 'lite_bootstrap'` (collection error). This is the failure we want — it proves the symbols don't yet exist. + +### Step 3: Create the bootstrapper module skeleton + +- [ ] Create `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` with the following content: + +```python +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.utilities.lifespan import combine_lifespans + 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() + + +@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 +): + 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 __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 +``` + +### 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__`: + +```python +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 +``` + +Add `"FastMcpBootstrapper"` and `"FastMcpConfig"` to `__all__`, keeping alphabetical order: + +```python +__all__ = [ + "BootstrapperNotReadyError", + "ConfigurationError", + "FastAPIBootstrapper", + "FastAPIConfig", + "FastMcpBootstrapper", + "FastMcpConfig", + "FastStreamBootstrapper", + "FastStreamConfig", + ... +] +``` + +### Step 5: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v` + +Expected: all three tests pass. + +If `test_fastmcp_bootstrapper_not_ready` errors with `Failed: DID NOT RAISE`, recheck that `FastMcpBootstrapper.is_ready()` reads `import_checker.is_fastmcp_installed` (not a cached module-level value). + +### Step 6: Commit + +```bash +git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py lite_bootstrap/__init__.py tests/test_fastmcp_bootstrap.py +git commit -m "feat: scaffold FastMcpBootstrapper and FastMcpConfig" +``` + +--- + +## Task 5: Verify teardown 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. + +**Files:** +- Modify: `tests/test_fastmcp_bootstrap.py` + +### Step 1: Write the failing tests + +- [ ] 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: + 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 +``` + +### Step 2: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py -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). + +### Step 3: Commit + +```bash +git add tests/test_fastmcp_bootstrap.py +git commit -m "test: verify FastMcpBootstrapper teardown via ASGI lifespan" +``` + +--- + +## Task 6: Add `FastMcpLoggingMiddleware` + +The MCP protocol-level middleware. Logs `method`, `source`, `type`, and duration to `mcp.access` structlog logger. 1:1 port of microbootstrap PR141's middleware. + +**Files:** +- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` +- Modify: `tests/test_fastmcp_bootstrap.py` + +### Step 1: Write the failing tests + +- [ ] Append to `tests/test_fastmcp_bootstrap.py`: + +```python +async def test_fastmcp_logging_middleware_logs_success(monkeypatch: pytest.MonkeyPatch) -> None: + from unittest.mock import MagicMock + + 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: + from unittest.mock import MagicMock + + 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 + + async def call_next(_: MiddlewareContext[typing.Any]) -> None: + raise CustomError("boom") + + 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() +``` + +### Step 2: Run tests to verify failure mode + +Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_success tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_exception -v` + +Expected: failure with `ImportError: cannot import name 'FastMcpLoggingMiddleware' from 'lite_bootstrap.bootstrappers.fastmcp_bootstrapper'` (collection error). + +### Step 3: Add the middleware to the bootstrapper module + +- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after the `_build_teardown_lifespan` function and before the `FastMcpConfig` dataclass, add: + +```python +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 +``` + +### Step 4: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_success tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_exception -v` + +Expected: both pass. + +### Step 5: Commit + +```bash +git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py +git commit -m "feat: add FastMcpLoggingMiddleware" +``` + +--- + +## Task 7: Add `FastMcpHealthChecksInstrument` and register it + +**Files:** +- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` +- Modify: `tests/test_fastmcp_bootstrap.py` + +### Step 1: Write the failing tests + +- [ ] Append to `tests/test_fastmcp_bootstrap.py`: + +```python +def _make_test_config(**overrides: typing.Any) -> FastMcpConfig: + 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() +``` + +### Step 2: Run tests to verify failure mode + +Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_health_check_route_serves_200_with_data tests/test_fastmcp_bootstrap.py::test_fastmcp_health_check_path_is_configurable tests/test_fastmcp_bootstrap.py::test_fastmcp_health_check_disabled_when_flag_false -v` + +Expected: all three fail with `404` (no health instrument registered yet). + +### Step 3: Add the instrument + +- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after `FastMcpLoggingMiddleware`, add: + +```python +@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())) +``` + +- [ ] Update `FastMcpBootstrapper.instruments_types` to include the new instrument: + +```python + instruments_types: typing.ClassVar = [ + FastMcpHealthChecksInstrument, + ] +``` + +### Step 4: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "health_check"` + +Expected: all three health-check tests pass. The earlier tests (`test_fastmcp_bootstrap_returns_same_application` etc.) still pass. + +### Step 5: Commit + +```bash +git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py +git commit -m "feat: add FastMcpHealthChecksInstrument" +``` + +--- + +## Task 8: Add `FastMcpPrometheusInstrument` and register it + +**Files:** +- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` +- Modify: `tests/test_fastmcp_bootstrap.py` + +### Step 1: Write the failing tests + +- [ ] Append to `tests/test_fastmcp_bootstrap.py`: + +```python +def test_fastmcp_prometheus_route_exposes_registered_metric() -> None: + counter_name = "fastmcp_plan_test_requests_total" + # Counter constructors register against the default registry by default; if a + # prior test registered the same name, reuse it rather than re-registering. + try: + counter = prometheus_client.Counter(counter_name, "FastMCP plan test counter.") + except ValueError: + # Already registered from a prior test in the same process. + 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() +``` + +### Step 2: Run tests to verify failure mode + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "prometheus"` + +Expected: both fail with `404` (no prometheus instrument registered). + +### Step 3: Add the instrument + +- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after `FastMcpHealthChecksInstrument`, add: + +```python +@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}, + ) +``` + +- [ ] Update `FastMcpBootstrapper.instruments_types`: + +```python + instruments_types: typing.ClassVar = [ + FastMcpHealthChecksInstrument, + FastMcpPrometheusInstrument, + ] +``` + +### Step 4: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "prometheus or health_check"` + +Expected: all five tests pass. + +### Step 5: Commit + +```bash +git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py +git commit -m "feat: add FastMcpPrometheusInstrument" +``` + +--- + +## Task 9: Add `FastMcpLoggingInstrument` and register it + +**Files:** +- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` +- Modify: `tests/test_fastmcp_bootstrap.py` + +### Step 1: Write the failing tests + +- [ ] Append to `tests/test_fastmcp_bootstrap.py`: + +```python +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() +``` + +### Step 2: Run tests to verify failure mode + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "logging_middleware_is_mounted or logging_middleware_disabled"` + +Expected: `test_fastmcp_logging_middleware_is_mounted_by_default` fails (`assert 0 == 1`), `test_fastmcp_logging_middleware_disabled_via_flag` passes incidentally. + +### Step 3: Add the instrument + +- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after `FastMcpPrometheusInstrument`, add: + +```python +@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()) +``` + +- [ ] Update `FastMcpBootstrapper.instruments_types` to its final form (PyroscopeInstrument, SentryInstrument, then framework-specific ones — matching `FastStreamBootstrapper`'s order): + +```python + instruments_types: typing.ClassVar = [ + PyroscopeInstrument, + SentryInstrument, + FastMcpHealthChecksInstrument, + FastMcpLoggingInstrument, + FastMcpPrometheusInstrument, + ] +``` + +### Step 4: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v` + +Expected: all tests pass. + +### Step 5: Commit + +```bash +git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py +git commit -m "feat: add FastMcpLoggingInstrument with MCP access middleware" +``` + +--- + +## Task 10: Add missing-dependency regression tests + +Per the established project pattern (`test_faststream_bootstrap.py::test_faststream_bootstrapper_with_missing_instrument_dependency`), every framework bootstrapper has a parametrized test that confirms missing optional dependencies surface as warnings, not crashes. + +**Files:** +- Modify: `tests/test_fastmcp_bootstrap.py` + +### Step 1: Write the failing tests + +- [ ] Update the imports at the top of `tests/test_fastmcp_bootstrap.py` to include the with-reload helper: + +```python +from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload +``` + +- [ ] Append to `tests/test_fastmcp_bootstrap.py`: + +```python +@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() +``` + +### Step 2: Run tests to verify pass + +Run: `just test -- tests/test_fastmcp_bootstrap.py -v` + +Expected: all tests pass. If `test_fastmcp_bootstrap_without_structlog` fails with a `NameError: name 'fastmcp_access_logger' is not defined`, recheck that `FastMcpLoggingInstrument.bootstrap()` shorts on `not import_checker.is_structlog_installed` *before* instantiating `FastMcpLoggingMiddleware`. + +### Step 3: Commit + +```bash +git add tests/test_fastmcp_bootstrap.py +git commit -m "test: cover missing optional dependencies for FastMcpBootstrapper" +``` + +--- + +## Task 11: Documentation updates + +Documents the new bootstrapper end-to-end. + +**Files:** +- Modify: `README.md` +- Modify: `docs/index.md` +- Modify: `docs/introduction/installation.md` +- Modify: `CLAUDE.md` +- Create: `docs/integrations/fastmcp.md` + +### Step 1: Update `README.md` + +- [ ] In `README.md` line 22–25, append `- [FastMCP](https://lite-bootstrap.readthedocs.io/integrations/fastmcp)` to the bullet list: + +```markdown +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) +``` + +### Step 2: Update `docs/index.md` + +- [ ] In `docs/index.md` line 19–22, append the matching bullet: + +```markdown +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) +``` + +### Step 3: Create `docs/integrations/fastmcp.md` + +- [ ] Write `docs/integrations/fastmcp.md` with the following content: + +````markdown +# 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. + +Read more about available configuration options [here](../../../introduction/configuration): +```` + +### Step 4: Update `docs/introduction/installation.md` + +- [ ] In the extras table (`docs/introduction/installation.md` lines 7–17), insert a new `FastMCP` column after `FastAPI`. The table becomes: + +```markdown +| 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` | +``` + +Above the table (after line 5), add the note: + +```markdown +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]`. +``` + +### Step 5: Update `CLAUDE.md` + +- [ ] In `CLAUDE.md` under "Core pattern" (the `BaseBootstrapper` tree), insert `FastMcpBootstrapper` before `FreeBootstrapper`: + +``` +BaseBootstrapper (abc.ABC) + ├── FastAPIBootstrapper + ├── LitestarBootstrapper + ├── FastStreamBootstrapper + ├── FastMcpBootstrapper + └── FreeBootstrapper +``` + +Do not add a row to the "Optional dependency groups" table — there is no `fastmcp-all` rollup, and the table only lists rollups. + +### Step 6: Commit + +```bash +git add README.md docs/index.md docs/introduction/installation.md CLAUDE.md docs/integrations/fastmcp.md +git commit -m "docs: document FastMcpBootstrapper integration" +``` + +--- + +## Task 12: Final lint, full test, and PR + +**Files:** (no source changes; verification + PR) + +### Step 1: Full lint + +Run: `just lint` + +Expected: clean exit. If `ty` reports issues, fix them inline before continuing. Typical pyright false positives (`reportPossiblyUnbound`, etc.) are suppressed project-wide per the existing `[tool.pyright]` block and should not surface; `ty` is the enforcing checker. + +### Step 2: Full test suite + +Run: `just test` + +Expected: full pass. Confirm `tests/test_fastmcp_bootstrap.py` reports ~15 tests, all passing. Total suite count should be ≈ previous_count + 15 (parametrized). + +### Step 3: Branch verification + +Run: `git log --oneline main..HEAD` + +Expected: 10 commits, in order: + +``` +docs: document FastMcpBootstrapper integration +test: cover missing optional dependencies for FastMcpBootstrapper +feat: add FastMcpLoggingInstrument with MCP access middleware +feat: add FastMcpPrometheusInstrument +feat: add FastMcpHealthChecksInstrument +feat: add FastMcpLoggingMiddleware +test: verify FastMcpBootstrapper teardown via ASGI lifespan +feat: scaffold FastMcpBootstrapper and FastMcpConfig +feat: add fastmcp and fastmcp-metrics extras +feat: detect fastmcp via import_checker +``` + +(Newest first.) + +### Step 4: Push and open PR + +```bash +git push -u origin feat/fastmcp-bootstrapper +``` + +Then open the PR: + +```bash +gh pr create --title "feat: add FastMcpBootstrapper" --body "$(cat <<'EOF' +## Summary + +- Add `FastMcpBootstrapper` and `FastMcpConfig` wiring the lite-bootstrap instrument stack (Sentry, Pyroscope, structlog logging with MCP-aware access middleware, health checks, Prometheus metrics) onto a FastMCP server. +- Plumb teardown through the FastMCP lifespan via `combine_lifespans` so shutdown runs deterministically on ASGI lifespan shutdown. +- Add `fastmcp` and `fastmcp-metrics` extras. No composite or rollup extras (project rule: extras must add a direct dependency). +- Document the integration (README, docs/index, new `docs/integrations/fastmcp.md`, extras table). + +Mirrors the instrument set merged upstream in [microbootstrap PR #141](https://github.com/community-of-python/microbootstrap/pull/141) and improves on it by wiring teardown. + +Spec: `docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md`. + +## Test plan + +- [x] `just test` — full suite passes (including 15 new fastmcp tests) +- [x] `just lint` — clean +- [ ] Manual smoke test: build a FastMCP server, register `/health/` and `/metrics`, hit them via `application.http_app()` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Return the PR URL. + +--- + +## Self-review checklist (run before handoff) + +1. **Spec coverage:** + - Config (`FastMcpConfig` + `application` field + `logging_turn_off_middleware`) → Task 4 Step 3. + - `FastMcpLoggingMiddleware` → Task 6. + - `FastMcpHealthChecksInstrument` → Task 7. + - `FastMcpPrometheusInstrument` → Task 8. + - `FastMcpLoggingInstrument` → Task 9. + - `FastMcpBootstrapper` + teardown lifespan wiring → Task 4 Step 3 (skeleton) + Task 5 (test). + - `is_fastmcp_installed` → Task 2. + - `fastmcp` + `fastmcp-metrics` extras → Task 3. + - All 13 spec tests → distributed across Tasks 4, 5, 6, 7, 8, 9; the missing-dependency parametrized test + structlog-absent guard from spec §Edge cases → Task 10. + - All docs changes → Task 11. + - PyPI keyword → Task 3 Step 2. + +2. **Placeholder scan:** No "TBD", "TODO", or "fill in" markers. Every code step contains the actual code. No "similar to Task N" references — each task is self-contained. + +3. **Type consistency:** + - `FastMcpConfig` used consistently in all instrument `bootstrap_config:` annotations (Tasks 4, 7, 8, 9). + - `FastMcpLoggingMiddleware` referenced by full name in Tasks 6 (definition), 9 (registration), 10 (regression test) — no typo variants. + - `application.lifespan` mutation pattern in Task 4 Step 3 matches the test in Task 5 Step 1 (no signature drift). + - `_make_test_config` helper introduced in Task 7 Step 1 is referenced in Tasks 8 and 9 — confirm Task 8/9 do NOT re-import it. + - `_drive_asgi_lifespan` helper introduced in Task 5 Step 1 — only used in Task 5 tests; no later task references it. + - `_find_mcp_logging_middleware` helper introduced in Task 9 Step 1 — only used in Task 9. + +4. **No new test deps:** All tests use stdlib (`contextlib`, `time`, `typing`, `unittest.mock`), already-installed test deps (`pytest`, `structlog`, `prometheus_client`), `starlette.testclient` (transitive via `fastmcp`), and `fastmcp` itself (Task 3 install). No new dev-group entries needed. diff --git a/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md b/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md new file mode 100644 index 0000000..8ea8a5e --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-fastmcp-bootstrapper-design.md @@ -0,0 +1,320 @@ +# FastMCP Bootstrapper Design + +**Date:** 2026-06-01 +**Reference:** [microbootstrap PR #141](https://github.com/community-of-python/microbootstrap/pull/141) — "Add fastmcp bootstrapper" (merged upstream). +**Deliverable:** A new `FastMcpBootstrapper` that wires the same instrument set used by `FastStreamBootstrapper`, minus OpenTelemetry/CORS/Swagger. Includes a FastMCP protocol-level access-log middleware, a Prometheus `/metrics` route, and a JSON `/health` route — all mounted on the user's `FastMCP` instance. + +This is a design spec, not an implementation plan. Per-PR sequencing and task-by-task execution are deferred to a follow-up plan produced by the `writing-plans` skill. + +--- + +## Goals + +- 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. + +## Non-goals + +- No OpenTelemetry instrument. MCP is JSON-RPC, not REST; ASGI-level OTel spans would only cover the HTTP transport and not the MCP method dimension. (Decided in Q1.) +- No CORS or Swagger instruments. MCP isn't browser-facing and doesn't ship an OpenAPI document. +- No "composite extras" (`fastmcp-sentry`, `fastmcp-logging`, `fastmcp-all`). Per the project's extras rule, an extra exists only when it pulls in a new dependency. (Decided in Q5.) +- No stdio-only behavior changes. The bootstrapper works whether the user calls `application.run(transport="stdio")` or `application.http_app()`. The HTTP-only instruments (health, metrics) silently no-op when no HTTP transport is used. + +--- + +## Architecture + +### Module layout + +| Path | Status | Purpose | +|------|--------|---------| +| `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` | new | `FastMcpConfig`, `FastMcpLoggingMiddleware`, framework-specific instrument subclasses, `FastMcpBootstrapper`. Single-file layout matches FastStream. | +| `lite_bootstrap/import_checker.py` | modified | Add `is_fastmcp_installed = find_spec("fastmcp") is not None`. | +| `lite_bootstrap/__init__.py` | modified | Re-export `FastMcpConfig`, `FastMcpBootstrapper`. | +| `pyproject.toml` | modified | Add `fastmcp = ["fastmcp"]`, `fastmcp-metrics = ["lite-bootstrap[fastmcp]", "prometheus-client>=0.20"]`. Append `"fastmcp"` to `keywords`. | +| `tests/test_fastmcp_bootstrap.py` | new | Unit + integration tests (full list below). | +| `README.md` | modified | Add FastMCP to the supported-frameworks list. | +| `docs/index.md` | modified | Same addition. | +| `docs/integrations/fastmcp.md` | new | Mirror `docs/integrations/faststream.md` structure. | +| `docs/introduction/installation.md` | modified | Add a FastMCP column to the extras table + a one-line note about no composite/rollup extras. | +| `CLAUDE.md` | modified | Add `FastMcpBootstrapper` to the Core-pattern tree. No row added to the "Optional dependency groups" table (no rollup extra). | + +No new package directories. `FastMcpLoggingMiddleware` lives in the same file as the bootstrapper, matching how `LitestarOpenTelemetryInstrumentationMiddleware` and FastStream's protocol classes are kept inline with their bootstrappers. + +### Class shape + +``` +FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig) + application: FastMCP[Any] # default_factory=_make_fastmcp + logging_turn_off_middleware: bool = False # opt-out for the MCP access log middleware + +FastMcpLoggingMiddleware(fastmcp.server.middleware.Middleware) + on_message: log method/source/type/duration via `mcp.access` structlog logger + +FastMcpHealthChecksInstrument(HealthChecksInstrument) + bootstrap: application.custom_route(path, methods=["GET"]) → JSONResponse(render_health_check_data()) + +FastMcpLoggingInstrument(LoggingInstrument) + bootstrap: super().bootstrap(); if not logging_turn_off_middleware and is_structlog_installed: + application.add_middleware(FastMcpLoggingMiddleware()) + +FastMcpPrometheusInstrument(PrometheusInstrument) + check_dependencies: is_prometheus_client_installed + missing_dependency_message: "prometheus_client is not installed" + bootstrap: application.custom_route(metrics_path, methods=["GET"]) → Response(generate_latest(), CONTENT_TYPE_LATEST) + +FastMcpBootstrapper(BaseBootstrapper["FastMCP[Any]"]) + instruments_types = [PyroscopeInstrument, SentryInstrument, + FastMcpHealthChecksInstrument, FastMcpLoggingInstrument, FastMcpPrometheusInstrument] + is_ready: import_checker.is_fastmcp_installed + __init__: super().__init__(config); wrap application.lifespan with teardown via combine_lifespans + _prepare_application: return self.bootstrap_config.application +``` + +`SentryInstrument` and `PyroscopeInstrument` are used unchanged — they configure global SDKs, not the application object. + +Instrument order (Pyroscope → Sentry → Health → Logging → Prometheus) mirrors `FastStreamBootstrapper.instruments_types` so cross-framework users see a consistent boot order. + +### FastMCP integration details (from FastMCP docs) + +- **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. + +--- + +## Config: `FastMcpConfig` + +```python +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 +``` + +- Frozen, `kw_only=True`, `slots=True` — matches every other framework config. +- `_make_fastmcp` is module-level so the default value is shared via factory (no mutable default footgun). +- `logging_turn_off_middleware` is a fastmcp-specific field, *not* a field on the shared `LoggingConfig` — only this bootstrapper installs an MCP middleware. The name matches microbootstrap PR141 for portability. +- No `UnsetType` sentinel (see CLAUDE.md "Key design decisions"): we don't need to derive any default-factory argument from sibling config fields. `FastMCP()` accepts no required arguments. + +--- + +## Instruments + +### `FastMcpLoggingMiddleware` + +```python +fastmcp_access_logger: typing.Final = structlog.get_logger("mcp.access") # gated by is_structlog_installed + + +class FastMcpLoggingMiddleware(Middleware): + async def on_message(self, context, call_next): + start = 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, + ) + raise + fastmcp_access_logger.info( + context.method or "unknown", mcp=mcp_fields, duration=time.perf_counter_ns() - start, + ) + return result +``` + +1:1 port of microbootstrap PR141's middleware. Logs one record per MCP message with method, source, type, and duration in nanoseconds. Uses a dedicated `mcp.access` structlog logger so consumers can filter/route MCP traffic independently of application logs. + +### `FastMcpHealthChecksInstrument(HealthChecksInstrument)` + +`bootstrap()` registers a `GET` route at `health_checks_path` (default `/health/`) returning `JSONResponse(self.render_health_check_data())`. `render_health_check_data()` returns the shared `HealthCheckTypedDict` (`service_name`, `service_version`, `health_status: True`) from the base instrument. Skipped when `health_checks_enabled=False` (handled by `HealthChecksInstrument.is_ready()`). + +### `FastMcpLoggingInstrument(LoggingInstrument)` + +`bootstrap()` calls `super().bootstrap()` (which configures structlog + foreign loggers), then conditionally calls `application.add_middleware(FastMcpLoggingMiddleware())` when both `logging_turn_off_middleware is False` and `is_structlog_installed is True`. The structlog guard is essential because `FastMcpLoggingMiddleware` references `fastmcp_access_logger`, which is only defined inside the `if import_checker.is_structlog_installed:` block. + +### `FastMcpPrometheusInstrument(PrometheusInstrument)` + +Overrides `check_dependencies()` to require `prometheus_client`, with `missing_dependency_message = "prometheus_client is not installed"` — matches `LitestarPrometheusInstrument` and `FastStreamPrometheusInstrument`. `bootstrap()` registers a `GET` route at `prometheus_metrics_path` that returns `Response(prometheus_client.generate_latest(prometheus_client.REGISTRY), headers={"content-type": prometheus_client.CONTENT_TYPE_LATEST})`. The default `prometheus_client.REGISTRY` is used (no fastmcp-specific registry field on the config), matching how the other lite-bootstrap framework configs treat the registry. + +--- + +## Bootstrapper: `FastMcpBootstrapper` + +```python +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) + 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. + +### Teardown wiring risk + +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: + +- 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. + +--- + +## Optional-import guards + +Top-level conditional imports inside `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`: + +```python +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 + +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 +``` + +`starlette` is a fastmcp transitive dependency, so the fastmcp guard covers it. Pyright will flag `reportPossiblyUnbound` on these — expected and suppressed project-wide per the `[tool.pyright]` block (see CLAUDE.md "Type checking"). `ty` (the enforced checker) handles the guard correctly. + +--- + +## Tests (`tests/test_fastmcp_bootstrap.py`) + +Async tests rely on the project's existing `asyncio_mode = "auto"` pytest-asyncio setting. No new fixtures needed beyond `conftest.py`. + +1. **`test_fastmcp_bootstrap_returns_fastmcp_instance`** — `FastMcpBootstrapper(FastMcpConfig(service_name="x", service_version="1")).bootstrap()` returns the same `FastMCP` instance, confirming `_prepare_application` short-circuits to the configured app. +2. **`test_fastmcp_health_check_route_serves_200_with_data`** — boot, hit `application.http_app()` via `httpx.AsyncClient` at `/health/`, assert 200 + JSON body matching `service_name`, `service_version`, `health_status: True`. +3. **`test_fastmcp_health_check_path_is_configurable`** — set `health_checks_path="/healthz"`, assert route mounted at that path. +4. **`test_fastmcp_health_check_disabled_when_flag_false`** — `health_checks_enabled=False`, assert `/health/` returns 404. +5. **`test_fastmcp_prometheus_route_exposes_registered_metric`** — register a counter on the default registry, boot, GET `/metrics`, assert `content-type` matches `prometheus_client.CONTENT_TYPE_LATEST` and the counter name appears in the body. +6. **`test_fastmcp_prometheus_path_is_configurable`** — set `prometheus_metrics_path="/m"`, assert mounted there. +7. **`test_fastmcp_logging_middleware_is_mounted_by_default`** — assert a `FastMcpLoggingMiddleware` instance appears in `application.middleware`. +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"`. + +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`). + +--- + +## Docs updates + +### `README.md` + +Append to the framework list (line 22–25): + +```markdown +- [FastMCP](https://lite-bootstrap.readthedocs.io/integrations/fastmcp) +``` + +### `docs/index.md` + +Same addition to the framework list (line 19–22). + +### `docs/integrations/fastmcp.md` (new) + +Mirror `docs/integrations/faststream.md` structure: + +- Section 1: install snippet for `uv add` / `pip install` / `poetry add` with extras `[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]` (manual composition since there's no `fastmcp-all` rollup). +- Section 2: bootstrap example. Construct `FastMcpConfig(service_name=..., service_version=..., sentry_dsn=..., ...)`, build the bootstrapper, call `.bootstrap()`, register a `@application.tool` to show the working surface. + +### `docs/introduction/installation.md` + +Add a FastMCP column to the extras table. Values: + +| Instrument | FastMCP | +|---------------|------------------------| +| sentry | `sentry` (compose) | +| prometheus | `fastmcp-metrics` | +| opentelemetry | not used | +| pyroscope | `pyroscope` | +| structlog | `logging` (compose) | +| cors | not used | +| swagger | not used | +| health-checks | no extra | +| all | no rollup (compose) | + +Add a one-line note above the table: "FastMCP has no per-pair (`fastmcp-sentry`, …) or rollup (`fastmcp-all`) extras because they would not pull in new dependencies — compose them yourself: `lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]`." + +### `CLAUDE.md` + +Add `FastMcpBootstrapper` to the `BaseBootstrapper` tree under "Core pattern": + +``` +BaseBootstrapper (abc.ABC) + ├── FastAPIBootstrapper + ├── LitestarBootstrapper + ├── FastStreamBootstrapper + ├── FastMcpBootstrapper + └── FreeBootstrapper +``` + +No new row in the "Optional dependency groups" table (there is no `fastmcp-all` rollup). + +### `pyproject.toml` + +Append `"fastmcp"` to the `keywords` list for PyPI discoverability. + +--- + +## Edge cases & decisions log + +- **Stdio-only servers**: `FastMcpBootstrapper` accepts a `FastMCP` instance that the user later runs via `transport="stdio"`. The Sentry, Pyroscope, and Logging instruments take effect; the health and metrics routes never receive traffic because no HTTP transport is mounted. No special handling — same as how `FastStreamBootstrapper` assumes the user calls `AsgiFastStream.run()` to expose its ASGI surface. +- **Health check on FastMCP without `http_app()`**: covered above — silently inert. If we ever want to support stdio-mode health pings via a separate channel, that's a future scope expansion, not part of this design. +- **User passes a `FastMCP` with an existing `add_middleware` chain**: our middleware is appended to whatever the user has; FastMCP's middleware list is order-significant (registration order = execution order), so we register last. Documented in the integrations page snippet. +- **`PrometheusInstrument.check_dependencies` override**: required because the shared `PrometheusInstrument` base doesn't gate on `prometheus_client` — the framework subclasses do. Same pattern as `LitestarPrometheusInstrument` and `FastStreamPrometheusInstrument`. +- **No backward-compat alias**: this is a fresh public name (`FastMcpBootstrapper`, `FastMcpConfig`). The "backward-compat aliases for renames" convention from CLAUDE.md doesn't apply. + +--- + +## Out of scope (explicit) + +- OpenTelemetry instrument for FastMCP (Q1). +- CORS / Swagger instruments for FastMCP. +- A "FastMCP examples" subdirectory under the repo (PR141 has one upstream; lite-bootstrap doesn't keep example apps in-repo). +- Migration tooling for users coming from microbootstrap (config-field surface is intentionally narrower). +- A `fastmcp-all` extra (Q5). + +--- + +## Open questions + +None at design time. The two judgment calls flagged during brainstorming — +"how does FastMCP expose lifespan mutation?" and "how does the MCP access logger +interact with structlog being absent?" — are resolved in the "Teardown wiring +risk" and "Optional-import guards" sections above. diff --git a/lite_bootstrap/instruments/pyroscope_instrument.py b/lite_bootstrap/instruments/pyroscope_instrument.py index 04938f2..f2fd2ec 100644 --- a/lite_bootstrap/instruments/pyroscope_instrument.py +++ b/lite_bootstrap/instruments/pyroscope_instrument.py @@ -31,6 +31,9 @@ def check_dependencies() -> bool: return import_checker.is_pyroscope_installed def bootstrap(self) -> None: + # is_ready() guarantees pyroscope_endpoint is set; assert documents the precondition + # for type narrowing and for direct callers that bypass the bootstrapper. + assert self.bootstrap_config.pyroscope_endpoint is not None namespace = self.bootstrap_config.opentelemetry_namespace tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags pyroscope.configure( diff --git a/pyproject.toml b/pyproject.toml index 6bf70b1..aa8acf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,7 +131,7 @@ lint = [ ] [build-system] -requires = ["uv_build"] +requires = ["uv_build<0.12"] build-backend = "uv_build" [tool.uv.build-backend]