From b1c348cc81130920ed5e6002a834e4e261a8b8fb Mon Sep 17 00:00:00 2001 From: reqww Date: Thu, 21 May 2026 10:46:57 +0300 Subject: [PATCH 1/5] Add fastmcp bootstrapper --- README.md | 24 ++++ .../plans/2026-05-21-fastmcp-bootstrapper.md | 52 ++++++++ .../2026-05-21-fastmcp-bootstrapper-design.md | 32 +++++ examples/fastmcp_app.py | 17 +++ microbootstrap/__init__.py | 7 +- microbootstrap/bootstrappers/fastmcp.py | 109 +++++++++++++++++ microbootstrap/bootstrappers/litestar.py | 4 +- microbootstrap/config/fastmcp.py | 42 +++++++ .../instruments/health_checks_instrument.py | 4 + .../instruments/prometheus_instrument.py | 5 + microbootstrap/middlewares/fastmcp.py | 46 +++++++ microbootstrap/settings.py | 14 +++ pyproject.toml | 1 + tests/bootstrappers/test_fastmcp.py | 114 ++++++++++++++++++ 14 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md create mode 100644 docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md create mode 100644 examples/fastmcp_app.py create mode 100644 microbootstrap/bootstrappers/fastmcp.py create mode 100644 microbootstrap/config/fastmcp.py create mode 100644 microbootstrap/middlewares/fastmcp.py create mode 100644 tests/bootstrappers/test_fastmcp.py diff --git a/README.md b/README.md index 8be588d..682493f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Those instruments can be bootstrapped for: - `fastapi`, - `litestar`, - or `faststream` service, +- or `fastmcp` service, - or even a service that doesn't use one of these frameworks. Interested? Let's dive right in ⚡ @@ -78,6 +79,7 @@ Also, you can specify extras during installation for concrete framework: - `fastapi` - `litestar` - `faststream` (ASGI app) +- `fastmcp` Also we have `granian` extra that is requires for `create_granian_server`. @@ -198,6 +200,28 @@ settings = YourSettings() application: AsgiFastStream = FastStreamBootstrapper(settings).bootstrap() ``` +### FastMCP + +```python +from fastmcp import FastMCP + +from microbootstrap import FastMcpSettings +from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper + + +class YourSettings(FastMcpSettings): + service_debug: bool = False + service_name: str = "my-awesome-mcp-service" + service_description: str = "MCP server for internal tools" + + sentry_dsn: str = "your-sentry-dsn" + + +settings = YourSettings() + +application: FastMCP = FastMcpBootstrapper(settings).bootstrap() +``` + ## Settings The settings object is the core of microbootstrap. diff --git a/docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md b/docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md new file mode 100644 index 0000000..a9f5763 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md @@ -0,0 +1,52 @@ +# 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:** Add a FastMCP bootstrapper with settings, config, exports, docs, examples, and tests. + +**Architecture:** Follow the existing `ApplicationBootstrapper` pattern. Return a native `fastmcp.FastMCP` server and +keep HTTP app options in config so callers can create ASGI apps when needed. + +**Tech Stack:** Python 3.10+, pydantic-settings, pytest, uv, FastMCP optional dependency. + +--- + +### Task 1: Add failing tests + +**Files:** +- Create: `tests/bootstrappers/test_fastmcp.py` + +- [ ] Write tests that import `FastMCP`, build a bootstrapper, assert service metadata reaches the server, assert + application config merges, and assert instrument configuration works. +- [ ] Run `uv run pytest tests/bootstrappers/test_fastmcp.py -q` and verify the tests fail because the module does + not exist yet. + +### Task 2: Add config, settings, bootstrapper, and exports + +**Files:** +- Create: `microbootstrap/config/fastmcp.py` +- Create: `microbootstrap/bootstrappers/fastmcp.py` +- Modify: `microbootstrap/settings.py` +- Modify: `microbootstrap/__init__.py` + +- [ ] Add `FastMcpConfig` with FastMCP constructor options and HTTP app options. +- [ ] Add `FastMcpSettings`. +- [ ] Add `FastMcpBootstrapper`. +- [ ] Export `FastMcpSettings`. +- [ ] Run the FastMCP bootstrapper tests and verify they pass. + +### Task 3: Add packaging, docs, and example + +**Files:** +- Modify: `pyproject.toml` +- Modify: `README.md` +- Create: `examples/fastmcp_app.py` + +- [ ] Add the `fastmcp` optional dependency group. +- [ ] Document installation and quickstart usage. +- [ ] Add an example FastMCP app with one tool. + +### Task 4: Verify + +- [ ] Run `uv run pytest tests/bootstrappers/test_fastmcp.py tests/test_settings.py -q`. +- [ ] Run `just lint`. diff --git a/docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md b/docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md new file mode 100644 index 0000000..9290e84 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md @@ -0,0 +1,32 @@ +# FastMCP Bootstrapper Design + +## Goal + +Add FastMCP as a first-class microbootstrap target next to FastAPI, Litestar, and FastStream. + +## Architecture + +The feature introduces `FastMcpBootstrapper`, backed by a focused `FastMcpConfig` dataclass and `FastMcpSettings` +settings model. The bootstrapper creates a `fastmcp.FastMCP` server through the existing +`ApplicationBootstrapper` lifecycle, so common instruments can keep using `bootstrap`, `bootstrap_before`, +`bootstrap_after`, and `teardown`. + +FastMCP has two useful surfaces: the native server object and an ASGI app returned by `http_app()`. The bootstrapper +returns the native server object. Callers configure the ASGI HTTP app through FastMCP's own `http_app()` interface. + +## Instruments + +The first version wires framework-independent instruments that already work without a web framework-specific adapter: +Sentry, Pyroscope, and Logging. FastMCP HTTP custom routes are used for Health checks and Prometheus. OpenTelemetry +needs a FastMCP-specific tracing adapter before it can be added safely. + +## Data Flow + +`FastMcpSettings` collects service metadata and instrument config from environment variables. `FastMcpBootstrapper` +initializes the configured instruments, merges their bootstrap config with `FastMcpConfig`, creates `fastmcp.FastMCP`, +and returns the server. + +## Testing + +Tests cover configuration merging, service metadata propagation, instrument configuration, and the HTTP app option +surface without requiring a running MCP transport. diff --git a/examples/fastmcp_app.py b/examples/fastmcp_app.py new file mode 100644 index 0000000..23ee6bc --- /dev/null +++ b/examples/fastmcp_app.py @@ -0,0 +1,17 @@ +from fastmcp import FastMCP + +from microbootstrap import FastMcpSettings +from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper + + +class Settings(FastMcpSettings): + service_name: str = "example-mcp" + service_description: str = "Example FastMCP service" + + +application: FastMCP = FastMcpBootstrapper(Settings()).bootstrap() + + +@application.tool +def greet_person(person_name: str) -> str: + return f"Hello, {person_name}!" diff --git a/microbootstrap/__init__.py b/microbootstrap/__init__.py index ae33bef..e980220 100644 --- a/microbootstrap/__init__.py +++ b/microbootstrap/__init__.py @@ -1,5 +1,5 @@ from microbootstrap.instruments.cors_instrument import CorsConfig -from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig +from microbootstrap.instruments.health_checks_instrument import FastMcpHealthChecksConfig, HealthChecksConfig from microbootstrap.instruments.logging_instrument import LoggingConfig from microbootstrap.instruments.opentelemetry_instrument import ( FastStreamOpentelemetryConfig, @@ -8,6 +8,7 @@ ) from microbootstrap.instruments.prometheus_instrument import ( FastApiPrometheusConfig, + FastMcpPrometheusConfig, FastStreamPrometheusConfig, FastStreamPrometheusMiddlewareProtocol, LitestarPrometheusConfig, @@ -17,6 +18,7 @@ from microbootstrap.instruments.swagger_instrument import SwaggerConfig from microbootstrap.settings import ( FastApiSettings, + FastMcpSettings, FastStreamSettings, InstrumentsSetupperSettings, LitestarSettings, @@ -27,6 +29,9 @@ "CorsConfig", "FastApiPrometheusConfig", "FastApiSettings", + "FastMcpHealthChecksConfig", + "FastMcpPrometheusConfig", + "FastMcpSettings", "FastStreamOpentelemetryConfig", "FastStreamPrometheusConfig", "FastStreamPrometheusMiddlewareProtocol", diff --git a/microbootstrap/bootstrappers/fastmcp.py b/microbootstrap/bootstrappers/fastmcp.py new file mode 100644 index 0000000..78c7bb4 --- /dev/null +++ b/microbootstrap/bootstrappers/fastmcp.py @@ -0,0 +1,109 @@ +from __future__ import annotations +import typing + +import prometheus_client +import typing_extensions +from fastmcp import FastMCP +from starlette.responses import JSONResponse, Response + +from microbootstrap.bootstrappers.base import ApplicationBootstrapper +from microbootstrap.config.fastmcp import FastMcpConfig +from microbootstrap.instruments.health_checks_instrument import ( + FastMcpHealthChecksConfig, + HealthChecksInstrument, + HealthCheckTypedDict, +) +from microbootstrap.instruments.logging_instrument import LoggingInstrument +from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig, PrometheusInstrument +from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument +from microbootstrap.instruments.sentry_instrument import SentryInstrument +from microbootstrap.middlewares.fastmcp import FastMcpLoggingMiddleware +from microbootstrap.settings import FastMcpSettings + + +if typing.TYPE_CHECKING: + from starlette.requests import Request + + +class KwargsFastMCP(FastMCP[typing.Any]): + def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401 + super().__init__(**kwargs) + + +class FastMcpBootstrapper( + ApplicationBootstrapper[FastMcpSettings, FastMCP[typing.Any], FastMcpConfig], +): + application_config = FastMcpConfig() + application_type = KwargsFastMCP + + def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]: + return { + "name": self.application_config.name or self.settings.service_name, + "instructions": self.application_config.instructions or self.settings.service_description, + "version": self.application_config.version or self.settings.service_version, + } + + def bootstrap_before_instruments_after_app_created( + self, + application: FastMCP[typing.Any], + ) -> FastMCP[typing.Any]: + self.console_writer.print_bootstrap_table() + return application + + +FastMcpBootstrapper.use_instrument()(SentryInstrument) +FastMcpBootstrapper.use_instrument()(PyroscopeInstrument) + + +@FastMcpBootstrapper.use_instrument() +class FastMcpLoggingInstrument(LoggingInstrument): + def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override] + if not self.instrument_config.logging_turn_off_middleware: + application.add_middleware(FastMcpLoggingMiddleware()) + return application + + +@FastMcpBootstrapper.use_instrument() +class FastMcpHealthChecksInstrument(HealthChecksInstrument): + def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override] + @application.custom_route( + self.instrument_config.health_checks_path, + methods=["GET"], + name="health_check", + include_in_schema=self.instrument_config.health_checks_include_in_schema, + ) + async def health_check_handler(request: Request) -> JSONResponse: # noqa: ARG001 + response_data: HealthCheckTypedDict = self.render_health_check_data() + return JSONResponse(response_data) + + return application + + @classmethod + def get_config_type(cls) -> type[FastMcpHealthChecksConfig]: + return FastMcpHealthChecksConfig + + +@FastMcpBootstrapper.use_instrument() +class FastMcpPrometheusInstrument(PrometheusInstrument[FastMcpPrometheusConfig]): + def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override] + if not self.instrument_config.prometheus_register_route: + return application + + @application.custom_route( + self.instrument_config.prometheus_metrics_path, + methods=["GET"], + name="metrics", + include_in_schema=self.instrument_config.prometheus_metrics_include_in_schema, + ) + async def metrics_handler(request: Request) -> Response: # noqa: ARG001 + registry: typing.Final = self.instrument_config.prometheus_registry or prometheus_client.REGISTRY + return Response( + prometheus_client.generate_latest(registry), + headers={"content-type": prometheus_client.CONTENT_TYPE_LATEST}, + ) + + return application + + @classmethod + def get_config_type(cls) -> type[FastMcpPrometheusConfig]: + return FastMcpPrometheusConfig diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index 736bedc..bf827a8 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -160,8 +160,8 @@ def __init__(self, config: OpenTelemetryConfig) -> None: def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddleware: return OpenTelemetryMiddleware( app=app, - client_request_hook=self.config.client_request_hook_handler, # type: ignore[arg-type] - client_response_hook=self.config.client_response_hook_handler, # type: ignore[arg-type] + client_request_hook=self.config.client_request_hook_handler, + client_response_hook=self.config.client_response_hook_handler, default_span_details=build_litestar_route_details_from_scope, excluded_urls=get_excluded_urls(self.config.exclude_urls_env_key), meter=self.config.meter, diff --git a/microbootstrap/config/fastmcp.py b/microbootstrap/config/fastmcp.py new file mode 100644 index 0000000..e51c16a --- /dev/null +++ b/microbootstrap/config/fastmcp.py @@ -0,0 +1,42 @@ +from __future__ import annotations +import dataclasses +import typing + + +if typing.TYPE_CHECKING: + import mcp.types + from fastmcp.client.sampling import SamplingHandler + from fastmcp.server.auth import AuthProvider + from fastmcp.server.lifespan import Lifespan + from fastmcp.server.middleware import Middleware as FastMcpMiddleware + from fastmcp.server.providers import Provider + from fastmcp.server.server import DuplicateBehavior, LifespanCallable + from fastmcp.server.transforms import Transform + from fastmcp.tools.base import Tool + from key_value.aio.protocols import AsyncKeyValue + + +@dataclasses.dataclass +class FastMcpConfig: + name: str | None = None + instructions: str | None = None + version: str | int | float | None = None + website_url: str | None = None + icons: list[mcp.types.Icon] | None = None + auth: AuthProvider | None = None + middleware: typing.Sequence[FastMcpMiddleware] | None = None + providers: typing.Sequence[Provider] | None = None + transforms: typing.Sequence[Transform] | None = None + lifespan: LifespanCallable | Lifespan | None = None + tools: typing.Sequence[Tool | typing.Callable[..., typing.Any]] | None = None + on_duplicate: DuplicateBehavior | None = None + mask_error_details: bool | None = None + dereference_schemas: bool = True + strict_input_validation: bool | None = None + list_page_size: int | None = None + tasks: bool | None = None + session_state_store: AsyncKeyValue | None = None + sampling_handler: SamplingHandler[typing.Any, typing.Any] | None = None + sampling_handler_behavior: typing.Literal["always", "fallback"] | None = None + client_log_level: mcp.types.LoggingLevel | None = None + experimental_capabilities: dict[str, dict[str, typing.Any]] | None = None diff --git a/microbootstrap/instruments/health_checks_instrument.py b/microbootstrap/instruments/health_checks_instrument.py index 9b0ee3f..1234151 100644 --- a/microbootstrap/instruments/health_checks_instrument.py +++ b/microbootstrap/instruments/health_checks_instrument.py @@ -23,6 +23,10 @@ class HealthChecksConfig(BaseInstrumentConfig): opentelemetry_generate_health_check_spans: bool = True +class FastMcpHealthChecksConfig(HealthChecksConfig): + health_checks_register_route: bool = True + + class HealthChecksInstrument(Instrument[HealthChecksConfig]): instrument_name = "Health checks" ready_condition = "Set health_checks_enabled to True" diff --git a/microbootstrap/instruments/prometheus_instrument.py b/microbootstrap/instruments/prometheus_instrument.py index 9936657..7d1f290 100644 --- a/microbootstrap/instruments/prometheus_instrument.py +++ b/microbootstrap/instruments/prometheus_instrument.py @@ -32,6 +32,11 @@ class FastApiPrometheusConfig(BasePrometheusConfig): prometheus_custom_labels: dict[str, typing.Any] = pydantic.Field(default_factory=dict) +class FastMcpPrometheusConfig(BasePrometheusConfig): + prometheus_registry: typing.Any | None = None + prometheus_register_route: bool = True + + @typing.runtime_checkable class FastStreamPrometheusMiddlewareProtocol(typing.Protocol): def __init__( diff --git a/microbootstrap/middlewares/fastmcp.py b/microbootstrap/middlewares/fastmcp.py new file mode 100644 index 0000000..488aa40 --- /dev/null +++ b/microbootstrap/middlewares/fastmcp.py @@ -0,0 +1,46 @@ +from __future__ import annotations +import time +import typing + +import structlog +from fastmcp.server.middleware import Middleware, MiddlewareContext + + +if typing.TYPE_CHECKING: + from fastmcp.server.middleware import CallNext + + +fastmcp_access_logger: typing.Final = structlog.get_logger("mcp.access") + + +class FastMcpLoggingMiddleware(Middleware): + async def on_message( + self, + context: MiddlewareContext[typing.Any], + call_next: CallNext[typing.Any, typing.Any], + ) -> typing.Any: # noqa: ANN401 + start_time: typing.Final = time.perf_counter_ns() + try: + result: typing.Final = await call_next(context) + except Exception: + fastmcp_access_logger.exception( + context.method or "unknown", + mcp={ + "method": context.method, + "source": context.source, + "type": context.type, + }, + duration=time.perf_counter_ns() - start_time, + ) + raise + + fastmcp_access_logger.info( + context.method or "unknown", + mcp={ + "method": context.method, + "source": context.source, + "type": context.type, + }, + duration=time.perf_counter_ns() - start_time, + ) + return result diff --git a/microbootstrap/settings.py b/microbootstrap/settings.py index a6a033a..8cc34fa 100644 --- a/microbootstrap/settings.py +++ b/microbootstrap/settings.py @@ -8,6 +8,8 @@ from microbootstrap import ( CorsConfig, FastApiPrometheusConfig, + FastMcpHealthChecksConfig, + FastMcpPrometheusConfig, FastStreamOpentelemetryConfig, FastStreamPrometheusConfig, HealthChecksConfig, @@ -102,6 +104,18 @@ class FastStreamSettings( # type: ignore[misc] asyncapi_path: str | None = "/asyncapi" +class FastMcpSettings( # type: ignore[misc] + BaseServiceSettings, + ServerConfig, + LoggingConfig, + SentryConfig, + FastMcpPrometheusConfig, + FastMcpHealthChecksConfig, + PyroscopeConfig, +): + """Settings for a fastmcp bootstrap.""" + + class InstrumentsSetupperSettings( # type: ignore[misc] BaseServiceSettings, LoggingConfig, diff --git a/pyproject.toml b/pyproject.toml index 65b7dbf..362fd1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ litestar = [ ] granian = ["granian[reload]>=1"] faststream = ["faststream~=0.6.2", "prometheus-client>=0.20"] +fastmcp = ["fastmcp>=2,<4", "prometheus-client>=0.20"] [dependency-groups] dev = [ diff --git a/tests/bootstrappers/test_fastmcp.py b/tests/bootstrappers/test_fastmcp.py new file mode 100644 index 0000000..0aa83ed --- /dev/null +++ b/tests/bootstrappers/test_fastmcp.py @@ -0,0 +1,114 @@ +import typing + +import prometheus_client +from fastmcp import FastMCP +from starlette import status +from starlette.testclient import TestClient + +from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper +from microbootstrap.config.fastmcp import FastMcpConfig +from microbootstrap.instruments.health_checks_instrument import FastMcpHealthChecksConfig +from microbootstrap.instruments.logging_instrument import LoggingConfig +from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig +from microbootstrap.middlewares.fastmcp import FastMcpLoggingMiddleware +from microbootstrap.settings import FastMcpSettings + + +def test_fastmcp_bootstrap_uses_service_metadata() -> None: + test_settings: typing.Final = FastMcpSettings( + service_name="test-mcp", + service_description="Test MCP service", + service_version="2.0.0", + ) + + application: typing.Final = FastMcpBootstrapper(test_settings).bootstrap() + + assert isinstance(application, FastMCP) + assert application.name == test_settings.service_name + assert application.instructions == test_settings.service_description + assert application.version == test_settings.service_version + + +def test_fastmcp_configure_application_overrides_defaults() -> None: + test_instructions: typing.Final = "Configured instructions" + + application: typing.Final = ( + FastMcpBootstrapper(FastMcpSettings()) + .configure_application(FastMcpConfig(instructions=test_instructions)) + .bootstrap() + ) + + assert application.instructions == test_instructions + + +def test_fastmcp_configure_instrument() -> None: + bootstrapper: typing.Final = FastMcpBootstrapper(FastMcpSettings()).configure_instrument( + LoggingConfig(logging_enabled=False), + ) + + application: typing.Final = bootstrapper.bootstrap() + + assert isinstance(application, FastMCP) + + +def test_fastmcp_logging_adds_mcp_middleware() -> None: + application: typing.Final = FastMcpBootstrapper(FastMcpSettings()).bootstrap() + + assert any(isinstance(middleware, FastMcpLoggingMiddleware) for middleware in application.middleware) + + +def test_fastmcp_logging_middleware_can_be_disabled() -> None: + application: typing.Final = ( + FastMcpBootstrapper(FastMcpSettings()) + .configure_instrument(LoggingConfig(logging_turn_off_middleware=True)) + .bootstrap() + ) + + assert not any(isinstance(middleware, FastMcpLoggingMiddleware) for middleware in application.middleware) + + +def test_fastmcp_http_app_is_configured_through_fastmcp_interface() -> None: + application: typing.Final = FastMcpBootstrapper(FastMcpSettings()).bootstrap() + + http_application: typing.Final = application.http_app(path="/api/mcp/", transport="http") + + assert any(getattr(route, "path", None) == "/api/mcp/" for route in http_application.routes) + + +def test_fastmcp_health_checks() -> None: + test_health_path: typing.Final = "/test-health/" + application: typing.Final = ( + FastMcpBootstrapper(FastMcpSettings()) + .configure_instrument(FastMcpHealthChecksConfig(health_checks_path=test_health_path)) + .bootstrap() + ) + + response: typing.Final = TestClient(application.http_app()).get(test_health_path) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["health_status"] is True + + +def test_fastmcp_prometheus() -> None: + test_metrics_path: typing.Final = "/test-metrics" + metrics_registry: typing.Final = prometheus_client.CollectorRegistry() + prometheus_client.Counter( + "fastmcp_test_requests_total", + "FastMCP test requests.", + registry=metrics_registry, + ).inc() + application: typing.Final = ( + FastMcpBootstrapper(FastMcpSettings()) + .configure_instrument( + FastMcpPrometheusConfig( + prometheus_metrics_path=test_metrics_path, + prometheus_registry=metrics_registry, + ), + ) + .bootstrap() + ) + + response: typing.Final = TestClient(application.http_app()).get(test_metrics_path) + + assert response.status_code == status.HTTP_200_OK + assert b"fastmcp_test_requests_total 1.0" in response.content From 2d529d02c25783bf94db974120d58eb6c707f1ac Mon Sep 17 00:00:00 2001 From: reqww Date: Thu, 21 May 2026 10:47:33 +0300 Subject: [PATCH 2/5] Add fastmcp bootstrapper --- .../plans/2026-05-21-fastmcp-bootstrapper.md | 52 ------------------- .../2026-05-21-fastmcp-bootstrapper-design.md | 32 ------------ 2 files changed, 84 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md delete mode 100644 docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md diff --git a/docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md b/docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md deleted file mode 100644 index a9f5763..0000000 --- a/docs/superpowers/plans/2026-05-21-fastmcp-bootstrapper.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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:** Add a FastMCP bootstrapper with settings, config, exports, docs, examples, and tests. - -**Architecture:** Follow the existing `ApplicationBootstrapper` pattern. Return a native `fastmcp.FastMCP` server and -keep HTTP app options in config so callers can create ASGI apps when needed. - -**Tech Stack:** Python 3.10+, pydantic-settings, pytest, uv, FastMCP optional dependency. - ---- - -### Task 1: Add failing tests - -**Files:** -- Create: `tests/bootstrappers/test_fastmcp.py` - -- [ ] Write tests that import `FastMCP`, build a bootstrapper, assert service metadata reaches the server, assert - application config merges, and assert instrument configuration works. -- [ ] Run `uv run pytest tests/bootstrappers/test_fastmcp.py -q` and verify the tests fail because the module does - not exist yet. - -### Task 2: Add config, settings, bootstrapper, and exports - -**Files:** -- Create: `microbootstrap/config/fastmcp.py` -- Create: `microbootstrap/bootstrappers/fastmcp.py` -- Modify: `microbootstrap/settings.py` -- Modify: `microbootstrap/__init__.py` - -- [ ] Add `FastMcpConfig` with FastMCP constructor options and HTTP app options. -- [ ] Add `FastMcpSettings`. -- [ ] Add `FastMcpBootstrapper`. -- [ ] Export `FastMcpSettings`. -- [ ] Run the FastMCP bootstrapper tests and verify they pass. - -### Task 3: Add packaging, docs, and example - -**Files:** -- Modify: `pyproject.toml` -- Modify: `README.md` -- Create: `examples/fastmcp_app.py` - -- [ ] Add the `fastmcp` optional dependency group. -- [ ] Document installation and quickstart usage. -- [ ] Add an example FastMCP app with one tool. - -### Task 4: Verify - -- [ ] Run `uv run pytest tests/bootstrappers/test_fastmcp.py tests/test_settings.py -q`. -- [ ] Run `just lint`. diff --git a/docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md b/docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md deleted file mode 100644 index 9290e84..0000000 --- a/docs/superpowers/specs/2026-05-21-fastmcp-bootstrapper-design.md +++ /dev/null @@ -1,32 +0,0 @@ -# FastMCP Bootstrapper Design - -## Goal - -Add FastMCP as a first-class microbootstrap target next to FastAPI, Litestar, and FastStream. - -## Architecture - -The feature introduces `FastMcpBootstrapper`, backed by a focused `FastMcpConfig` dataclass and `FastMcpSettings` -settings model. The bootstrapper creates a `fastmcp.FastMCP` server through the existing -`ApplicationBootstrapper` lifecycle, so common instruments can keep using `bootstrap`, `bootstrap_before`, -`bootstrap_after`, and `teardown`. - -FastMCP has two useful surfaces: the native server object and an ASGI app returned by `http_app()`. The bootstrapper -returns the native server object. Callers configure the ASGI HTTP app through FastMCP's own `http_app()` interface. - -## Instruments - -The first version wires framework-independent instruments that already work without a web framework-specific adapter: -Sentry, Pyroscope, and Logging. FastMCP HTTP custom routes are used for Health checks and Prometheus. OpenTelemetry -needs a FastMCP-specific tracing adapter before it can be added safely. - -## Data Flow - -`FastMcpSettings` collects service metadata and instrument config from environment variables. `FastMcpBootstrapper` -initializes the configured instruments, merges their bootstrap config with `FastMcpConfig`, creates `fastmcp.FastMCP`, -and returns the server. - -## Testing - -Tests cover configuration merging, service metadata propagation, instrument configuration, and the HTTP app option -surface without requiring a running MCP transport. From 61f08e6aea85e644b96f7166b52c9f538844f39f Mon Sep 17 00:00:00 2001 From: reqww Date: Thu, 21 May 2026 10:53:22 +0300 Subject: [PATCH 3/5] Add fastmcp bootstrapper --- tests/bootstrappers/test_fastmcp.py | 2 -- tests/middlewares/test_fastmcp.py | 52 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/middlewares/test_fastmcp.py diff --git a/tests/bootstrappers/test_fastmcp.py b/tests/bootstrappers/test_fastmcp.py index 0aa83ed..b249f62 100644 --- a/tests/bootstrappers/test_fastmcp.py +++ b/tests/bootstrappers/test_fastmcp.py @@ -88,8 +88,6 @@ def test_fastmcp_health_checks() -> None: assert response.status_code == status.HTTP_200_OK assert response.json()["health_status"] is True - -def test_fastmcp_prometheus() -> None: test_metrics_path: typing.Final = "/test-metrics" metrics_registry: typing.Final = prometheus_client.CollectorRegistry() prometheus_client.Counter( diff --git a/tests/middlewares/test_fastmcp.py b/tests/middlewares/test_fastmcp.py new file mode 100644 index 0000000..7cca9d1 --- /dev/null +++ b/tests/middlewares/test_fastmcp.py @@ -0,0 +1,52 @@ +import typing +from unittest.mock import MagicMock + +import pytest +from fastmcp.server.middleware import MiddlewareContext + +from microbootstrap.middlewares.fastmcp import FastMcpLoggingMiddleware + + +async def test_fastmcp_logging_middleware_logs_success(monkeypatch: pytest.MonkeyPatch) -> None: + fake_logger: typing.Final = MagicMock() + middleware: typing.Final = FastMcpLoggingMiddleware() + middleware_context: typing.Final = MiddlewareContext( + message={"payload": "test"}, + method="tools/list", + source="client", + type="request", + ) + + async def call_next(context: MiddlewareContext[typing.Any]) -> dict[str, str]: + assert context is middleware_context + return {"status": "ok"} + + monkeypatch.setattr("microbootstrap.middlewares.fastmcp.fastmcp_access_logger", fake_logger) + + result: typing.Final = await middleware.on_message(middleware_context, call_next) + + assert result == {"status": "ok"} + fake_logger.info.assert_called_once() + + +async def test_fastmcp_logging_middleware_logs_exception(monkeypatch: pytest.MonkeyPatch) -> None: + fake_logger: typing.Final = MagicMock() + middleware: typing.Final = FastMcpLoggingMiddleware() + middleware_context: typing.Final = MiddlewareContext( + message={"payload": "test"}, + method="tools/call", + source="client", + type="request", + ) + + async def call_next(context: MiddlewareContext[typing.Any]) -> dict[str, str]: + assert context is middleware_context + msg = "MCP call failed" + raise RuntimeError(msg) + + monkeypatch.setattr("microbootstrap.middlewares.fastmcp.fastmcp_access_logger", fake_logger) + + with pytest.raises(RuntimeError, match="MCP call failed"): + await middleware.on_message(middleware_context, call_next) + + fake_logger.exception.assert_called_once() From 46e1e22fdac1dea1651d6b95d8470c0b131d193c Mon Sep 17 00:00:00 2001 From: reqww Date: Thu, 21 May 2026 10:54:28 +0300 Subject: [PATCH 4/5] Add fastmcp bootstrapper --- tests/middlewares/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/middlewares/__init__.py diff --git a/tests/middlewares/__init__.py b/tests/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 From 463d3aa31d0d3c82e6b912348624ada3c256ab2a Mon Sep 17 00:00:00 2001 From: reqww Date: Thu, 21 May 2026 10:54:55 +0300 Subject: [PATCH 5/5] Add fastmcp bootstrapper --- microbootstrap/__init__.py | 3 +- microbootstrap/bootstrappers/fastmcp.py | 10 +---- .../instruments/health_checks_instrument.py | 4 -- microbootstrap/settings.py | 3 +- tests/bootstrappers/test_fastmcp.py | 42 ++++++++++++++++++- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/microbootstrap/__init__.py b/microbootstrap/__init__.py index e980220..7c8295a 100644 --- a/microbootstrap/__init__.py +++ b/microbootstrap/__init__.py @@ -1,5 +1,5 @@ from microbootstrap.instruments.cors_instrument import CorsConfig -from microbootstrap.instruments.health_checks_instrument import FastMcpHealthChecksConfig, HealthChecksConfig +from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig from microbootstrap.instruments.logging_instrument import LoggingConfig from microbootstrap.instruments.opentelemetry_instrument import ( FastStreamOpentelemetryConfig, @@ -29,7 +29,6 @@ "CorsConfig", "FastApiPrometheusConfig", "FastApiSettings", - "FastMcpHealthChecksConfig", "FastMcpPrometheusConfig", "FastMcpSettings", "FastStreamOpentelemetryConfig", diff --git a/microbootstrap/bootstrappers/fastmcp.py b/microbootstrap/bootstrappers/fastmcp.py index 78c7bb4..656c0d1 100644 --- a/microbootstrap/bootstrappers/fastmcp.py +++ b/microbootstrap/bootstrappers/fastmcp.py @@ -8,11 +8,7 @@ from microbootstrap.bootstrappers.base import ApplicationBootstrapper from microbootstrap.config.fastmcp import FastMcpConfig -from microbootstrap.instruments.health_checks_instrument import ( - FastMcpHealthChecksConfig, - HealthChecksInstrument, - HealthCheckTypedDict, -) +from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict from microbootstrap.instruments.logging_instrument import LoggingInstrument from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig, PrometheusInstrument from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument @@ -78,10 +74,6 @@ async def health_check_handler(request: Request) -> JSONResponse: # noqa: ARG00 return application - @classmethod - def get_config_type(cls) -> type[FastMcpHealthChecksConfig]: - return FastMcpHealthChecksConfig - @FastMcpBootstrapper.use_instrument() class FastMcpPrometheusInstrument(PrometheusInstrument[FastMcpPrometheusConfig]): diff --git a/microbootstrap/instruments/health_checks_instrument.py b/microbootstrap/instruments/health_checks_instrument.py index 1234151..9b0ee3f 100644 --- a/microbootstrap/instruments/health_checks_instrument.py +++ b/microbootstrap/instruments/health_checks_instrument.py @@ -23,10 +23,6 @@ class HealthChecksConfig(BaseInstrumentConfig): opentelemetry_generate_health_check_spans: bool = True -class FastMcpHealthChecksConfig(HealthChecksConfig): - health_checks_register_route: bool = True - - class HealthChecksInstrument(Instrument[HealthChecksConfig]): instrument_name = "Health checks" ready_condition = "Set health_checks_enabled to True" diff --git a/microbootstrap/settings.py b/microbootstrap/settings.py index 8cc34fa..5f9bce8 100644 --- a/microbootstrap/settings.py +++ b/microbootstrap/settings.py @@ -8,7 +8,6 @@ from microbootstrap import ( CorsConfig, FastApiPrometheusConfig, - FastMcpHealthChecksConfig, FastMcpPrometheusConfig, FastStreamOpentelemetryConfig, FastStreamPrometheusConfig, @@ -110,7 +109,7 @@ class FastMcpSettings( # type: ignore[misc] LoggingConfig, SentryConfig, FastMcpPrometheusConfig, - FastMcpHealthChecksConfig, + HealthChecksConfig, PyroscopeConfig, ): """Settings for a fastmcp bootstrap.""" diff --git a/tests/bootstrappers/test_fastmcp.py b/tests/bootstrappers/test_fastmcp.py index b249f62..22c6af3 100644 --- a/tests/bootstrappers/test_fastmcp.py +++ b/tests/bootstrappers/test_fastmcp.py @@ -7,7 +7,7 @@ from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper from microbootstrap.config.fastmcp import FastMcpConfig -from microbootstrap.instruments.health_checks_instrument import FastMcpHealthChecksConfig +from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig from microbootstrap.instruments.logging_instrument import LoggingConfig from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig from microbootstrap.middlewares.fastmcp import FastMcpLoggingMiddleware @@ -79,7 +79,7 @@ def test_fastmcp_health_checks() -> None: test_health_path: typing.Final = "/test-health/" application: typing.Final = ( FastMcpBootstrapper(FastMcpSettings()) - .configure_instrument(FastMcpHealthChecksConfig(health_checks_path=test_health_path)) + .configure_instrument(HealthChecksConfig(health_checks_path=test_health_path)) .bootstrap() ) @@ -88,6 +88,26 @@ def test_fastmcp_health_checks() -> None: assert response.status_code == status.HTTP_200_OK assert response.json()["health_status"] is True + +def test_fastmcp_health_checks_route_can_be_disabled_with_existing_enabled_flag() -> None: + test_health_path: typing.Final = "/test-health/" + application: typing.Final = ( + FastMcpBootstrapper(FastMcpSettings()) + .configure_instrument( + HealthChecksConfig( + health_checks_path=test_health_path, + health_checks_enabled=False, + ), + ) + .bootstrap() + ) + + response: typing.Final = TestClient(application.http_app()).get(test_health_path) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_fastmcp_prometheus() -> None: test_metrics_path: typing.Final = "/test-metrics" metrics_registry: typing.Final = prometheus_client.CollectorRegistry() prometheus_client.Counter( @@ -110,3 +130,21 @@ def test_fastmcp_health_checks() -> None: assert response.status_code == status.HTTP_200_OK assert b"fastmcp_test_requests_total 1.0" in response.content + + +def test_fastmcp_prometheus_route_can_be_disabled() -> None: + test_metrics_path: typing.Final = "/test-metrics" + application: typing.Final = ( + FastMcpBootstrapper(FastMcpSettings()) + .configure_instrument( + FastMcpPrometheusConfig( + prometheus_metrics_path=test_metrics_path, + prometheus_register_route=False, + ), + ) + .bootstrap() + ) + + response: typing.Final = TestClient(application.http_app()).get(test_metrics_path) + + assert response.status_code == status.HTTP_404_NOT_FOUND