Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
652b7dd
feat: detect fastmcp via import_checker
lesnik512 Jun 1, 2026
74b0918
feat: add fastmcp and fastmcp-metrics extras
lesnik512 Jun 1, 2026
97a741b
feat: scaffold FastMcpBootstrapper and FastMcpConfig
lesnik512 Jun 1, 2026
070408f
test: verify FastMcpBootstrapper teardown resets state
lesnik512 Jun 1, 2026
a9a3842
feat: add FastMcpLoggingMiddleware
lesnik512 Jun 1, 2026
466c01e
fix: guard FastMcpLoggingMiddleware class definition behind fastmcp i…
lesnik512 Jun 1, 2026
7014b41
feat: add FastMcpHealthChecksInstrument
lesnik512 Jun 1, 2026
f7d1b8b
feat: add FastMcpPrometheusInstrument
lesnik512 Jun 1, 2026
a54f51f
docs: add instrument-skip-rework design spec
lesnik512 Jun 1, 2026
3920344
feat: add FastMcpLoggingInstrument with MCP access middleware
lesnik512 Jun 1, 2026
23f705d
test: cover missing optional dependencies for FastMcpBootstrapper
lesnik512 Jun 1, 2026
c7682eb
docs: update fastmcp spec/plan to reflect manual-teardown discovery
lesnik512 Jun 1, 2026
11d49e5
docs: document FastMcpBootstrapper integration
lesnik512 Jun 1, 2026
f6e61d6
test: tighten FastMcpBootstrapper not-ready check to BootstrapperNotR…
lesnik512 Jun 1, 2026
b786781
feat: wire FastMcpBootstrapper teardown via FastMCP Provider.lifespan
lesnik512 Jun 1, 2026
bc7e287
feat: add fastmcp-all rollup extra
lesnik512 Jun 1, 2026
7a10201
fix: stop passing None broker positionally to AsgiFastStream in tests
lesnik512 Jun 1, 2026
5a85a19
refactor: drop redundant is_structlog_installed guard in FastMcpLoggi…
lesnik512 Jun 1, 2026
0bfbc8f
docs: sync fastmcp spec/plan with Provider.lifespan teardown, comment…
lesnik512 Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ BaseBootstrapper (abc.ABC)
├── FastAPIBootstrapper
├── LitestarBootstrapper
├── FastStreamBootstrapper
├── FastMcpBootstrapper
└── FreeBootstrapper
```

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Those instruments can be bootstrapped for:
- [LiteStar](https://lite-bootstrap.readthedocs.io/integrations/litestar)
- [FastStream](https://lite-bootstrap.readthedocs.io/integrations/faststream)
- [FastAPI](https://lite-bootstrap.readthedocs.io/integrations/fastapi)
- [FastMCP](https://lite-bootstrap.readthedocs.io/integrations/fastmcp)
- [services and scripts without frameworks](https://lite-bootstrap.readthedocs.io/integrations/free)
---

Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ Those instruments can be bootstrapped for:
- [LiteStar](integrations/litestar)
- [FastStream](integrations/faststream)
- [FastAPI](integrations/fastapi)
- [FastMCP](integrations/fastmcp)
- [services and scripts without frameworks](integrations/free)
---
57 changes: 57 additions & 0 deletions docs/integrations/fastmcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Usage with `FastMCP`

## 1. Install `lite-bootstrap[fastmcp-all]`:

=== "uv"

```bash
uv add lite-bootstrap[fastmcp-all]
```

=== "pip"

```bash
pip install lite-bootstrap[fastmcp-all]
```

=== "poetry"

```bash
poetry add lite-bootstrap[fastmcp-all]
```

Read more about available extras [here](../../../introduction/installation):

## 2. Define bootstrapper config and build your application:

```python
from fastmcp import FastMCP
from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig


bootstrapper_config = FastMcpConfig(
service_name="microservice",
service_version="2.0.0",
service_environment="test",
sentry_dsn="https://testdsn@localhost/1",
prometheus_metrics_path="/custom-metrics/",
health_checks_path="/custom-health/",
logging_buffer_capacity=0,
)
bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config)
application: FastMCP = bootstrapper.bootstrap()


@application.tool
def greet_person(person_name: str) -> str:
return f"Hello, {person_name}!"
```

Set `logging_turn_off_middleware=True` on the config to disable the per-MCP-message
access log middleware. Set `health_checks_enabled=False` to omit the health route.

Teardown is wired through FastMCP's provider lifecycle — `bootstrapper.teardown()`
runs automatically when the FastMCP server's ASGI lifespan shuts down (i.e. when
the application that serves `application.http_app()` shuts down).

Read more about available configuration options [here](../../../introduction/configuration):
22 changes: 11 additions & 11 deletions docs/introduction/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@

You can choose required framework and instruments using this table:

| Instrument | Litestar | Faststream | FastAPI | Free Bootstrapper, without framework |
|---------------|--------------------|----------------------|-------------------|--------------------------------------|
| sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` |
| prometheus | `litestar-metrics` | `faststream-metrics` | `fastapi-metrics` | not used |
| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | `otl` |
| pyroscope | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` |
| structlog | `litestar-logging` | `faststream-logging` | `fastapi-logging` | `logging` |
| cors | no extra | not used | no extra | not used |
| swagger | no extra | not used | no extra | not used |
| health-checks | no extra | no extra | no extra | not used |
| all | `litestar-all` | `faststream-all` | `fastapi-all` | `free-all` |
| Instrument | Litestar | Faststream | FastAPI | FastMCP | Free Bootstrapper, without framework |
|---------------|--------------------|----------------------|-------------------|---------------------|--------------------------------------|
| sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` (compose) | `sentry` |
| prometheus | `litestar-metrics` | `faststream-metrics` | `fastapi-metrics` | `fastmcp-metrics` | not used |
| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | not used | `otl` |
| pyroscope | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` |
| structlog | `litestar-logging` | `faststream-logging` | `fastapi-logging` | `logging` (compose) | `logging` |
| cors | no extra | not used | no extra | not used | not used |
| swagger | no extra | not used | no extra | not used | not used |
| health-checks | no extra | no extra | no extra | no extra | not used |
| all | `litestar-all` | `faststream-all` | `fastapi-all` | `fastmcp-all` | `free-all` |

* not used - means that the instrument is not implemented in the integration.
* no extra - means that the instrument requires no additional dependencies.
Expand Down
125 changes: 43 additions & 82 deletions docs/superpowers/plans/2026-06-01-fastmcp-bootstrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Two new files, six modified files.
- **Instrument set:** Sentry, Pyroscope, structlog logging + MCP middleware, health, prometheus. No OTel/CORS/Swagger.
- **Middleware default:** mounted on; `logging_turn_off_middleware: bool = False` flag to opt out.
- **Prometheus route:** `application.custom_route` at `prometheus_metrics_path`, always-on (no per-bootstrapper opt-out flag).
- **Teardown:** wrap `FastMCP.lifespan` via `combine_lifespans`. Risk: assumes mutability of `FastMCP.lifespan` — guarded by the lifespan-replay test in Task 10.
- **Extras:** only `fastmcp` and `fastmcp-metrics`. No `fastmcp-sentry` / `fastmcp-logging` / `fastmcp-all` because they would not pull in a new direct dependency.
- **Teardown:** wired automatically via `FastMCP.add_provider(_TeardownProvider(self.teardown))` in `FastMcpBootstrapper.__init__`. `_TeardownProvider` is an empty `Provider` subclass whose `async def lifespan(self)` calls the teardown callable on exit. This is the only **public, post-construction** lifecycle hook FastMCP exposes — `FastMCP.lifespan` is a read-only bound method and `_lifespan` is private. Two earlier approaches considered and rejected: wrapping `FastMCP.lifespan` via `combine_lifespans` (impossible — `lifespan` is read-only) and manual teardown (briefly adopted then reverted when `add_provider` was identified). See spec §"Teardown via Provider.lifespan".
- **Extras:** `fastmcp`, `fastmcp-metrics`, and `fastmcp-all` rollup (matches `fastapi-all` / `litestar-all` / `faststream-all`). No `fastmcp-sentry` / `fastmcp-logging` because per-pair composites add no new direct dependencies.
- **Default config app:** `default_factory=_make_fastmcp` where `_make_fastmcp()` returns `FastMCP()`. No `UnsetType` sentinel — `FastMCP()` needs no derived config.
- **Middleware default registry:** `prometheus_client.REGISTRY`. No fastmcp-specific registry config field.

Expand Down Expand Up @@ -221,7 +221,6 @@ Expected: All three tests fail with `ImportError: cannot import name 'FastMcpBoo
- [ ] Create `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` with the following content:

```python
import contextlib
import dataclasses
import time
import typing
Expand All @@ -238,7 +237,6 @@ from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryIns
if import_checker.is_fastmcp_installed:
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.utilities.lifespan import combine_lifespans
from starlette.requests import Request
from starlette.responses import JSONResponse, Response

Expand All @@ -255,24 +253,6 @@ def _make_fastmcp() -> "FastMCP[typing.Any]":
return FastMCP()


@contextlib.asynccontextmanager
async def _empty_lifespan(_: "FastMCP[typing.Any]") -> typing.AsyncIterator[dict[str, typing.Any]]:
yield {}


def _build_teardown_lifespan(
teardown: typing.Callable[[], None],
) -> typing.Callable[["FastMCP[typing.Any]"], typing.AsyncContextManager[dict[str, typing.Any]]]:
@contextlib.asynccontextmanager
async def lifespan(_: "FastMCP[typing.Any]") -> typing.AsyncIterator[dict[str, typing.Any]]:
try:
yield {}
finally:
teardown()

return lifespan


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class FastMcpConfig(
HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig
Expand All @@ -291,16 +271,12 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]):
def is_ready(self) -> bool:
return import_checker.is_fastmcp_installed

def __init__(self, bootstrap_config: FastMcpConfig) -> None:
super().__init__(bootstrap_config)
application = self.bootstrap_config.application
existing_lifespan = application.lifespan if application.lifespan is not None else _empty_lifespan
application.lifespan = combine_lifespans(existing_lifespan, _build_teardown_lifespan(self.teardown))

def _prepare_application(self) -> "FastMCP[typing.Any]":
return self.bootstrap_config.application
```

Note: no `__init__` override and no lifespan wiring. `FastMCP.lifespan` was empirically determined to be a read-only bound method, with the real hook stored in private `_lifespan` at construction time. Per the spec's documented fallback, teardown is manual — users call `bootstrapper.teardown()` themselves.

### Step 4: Re-export from package __init__

- [ ] Update `lite_bootstrap/__init__.py`. Insert imports alphabetically (after the existing `faststream_bootstrapper` import) and add the names to `__all__`:
Expand Down Expand Up @@ -344,80 +320,37 @@ git commit -m "feat: scaffold FastMcpBootstrapper and FastMcpConfig"

---

## Task 5: Verify teardown via ASGI lifespan
## Task 5: Verify teardown resets is_bootstrapped (direct + via ASGI lifespan)

Adds the lifespan-replay test that exercises `FastMCP.lifespan` mutation end-to-end. This proves the (currently empty-instrument) bootstrapper's teardown wiring works before we layer instruments on top of it.
Initially this task covered only the direct-call assertion because the design first concluded teardown had to be manual (the `FastMCP.lifespan` read-only finding). Once `add_provider` + `Provider.lifespan` was identified as the right post-construction hook, the ASGI-lifespan replay test was restored alongside the direct test. Both land in the same task. (The earlier `test_fastmcp_bootstrap_returns_same_application` test calls `teardown()` at the end too; this task makes the assertion explicit and adds the lifespan-driven counterpart.)

**Files:**
- Modify: `tests/test_fastmcp_bootstrap.py`

### Step 1: Write the failing tests
### Step 1: Write the failing test

- [ ] Append to `tests/test_fastmcp_bootstrap.py`:

```python
async def _drive_asgi_lifespan(application: typing.Any) -> list[dict[str, typing.Any]]:
"""Drive an ASGI lifespan from startup through shutdown. Returns the sent messages."""
inbox = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]
outbox: list[dict[str, typing.Any]] = []

async def receive() -> dict[str, typing.Any]:
return inbox.pop(0)

async def send(message: dict[str, typing.Any]) -> None:
outbox.append(message)

await application({"type": "lifespan", "asgi": {"version": "3.0"}}, receive, send)
return outbox


async def test_fastmcp_teardown_runs_via_asgi_lifespan() -> None:
def test_fastmcp_teardown_resets_is_bootstrapped() -> None:
bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig())
application = bootstrapper.bootstrap()
assert bootstrapper.is_bootstrapped

http_app = application.http_app()
sent = await _drive_asgi_lifespan(http_app)

assert any(message["type"] == "lifespan.startup.complete" for message in sent)
assert any(message["type"] == "lifespan.shutdown.complete" for message in sent)
assert not bootstrapper.is_bootstrapped


async def test_fastmcp_existing_user_lifespan_is_preserved() -> None:
user_state: dict[str, bool] = {"startup": False, "shutdown": False}

@contextlib.asynccontextmanager
async def user_lifespan(_: FastMCP) -> typing.AsyncIterator[dict[str, typing.Any]]:
user_state["startup"] = True
try:
yield {}
finally:
user_state["shutdown"] = True

config = FastMcpConfig(application=FastMCP(lifespan=user_lifespan))
bootstrapper = FastMcpBootstrapper(bootstrap_config=config)
application = bootstrapper.bootstrap()

http_app = application.http_app()
await _drive_asgi_lifespan(http_app)

assert user_state["startup"] is True
assert user_state["shutdown"] is True
assert not bootstrapper.is_bootstrapped
bootstrapper.bootstrap()
assert bootstrapper.is_bootstrapped is True
bootstrapper.teardown()
assert bootstrapper.is_bootstrapped is False
```

### Step 2: Run tests to verify pass

Run: `just test -- tests/test_fastmcp_bootstrap.py -v`
Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_teardown_resets_is_bootstrapped -v`

Expected: both new tests pass. If `application.lifespan` mutation rejects (`AttributeError` during `FastMcpBootstrapper.__init__`), the design's stated risk has materialized; stop and re-open the design (the fallback would be to wrap on `_prepare_application` instead).
Expected: passes immediately (the assertions only exercise the base class's idempotent teardown plumbing, which already works).

### Step 3: Commit

```bash
git add tests/test_fastmcp_bootstrap.py
git commit -m "test: verify FastMcpBootstrapper teardown via ASGI lifespan"
git commit -m "test: verify FastMcpBootstrapper teardown resets state"
```

---
Expand Down Expand Up @@ -1035,6 +968,34 @@ def greet_person(person_name: str) -> str:
Set `logging_turn_off_middleware=True` on the config to disable the per-MCP-message
access log middleware. Set `health_checks_enabled=False` to omit the health route.

## 3. Teardown

`FastMcpBootstrapper` does not wire teardown automatically (FastMCP captures its
lifespan at construction time only). Call `bootstrapper.teardown()` yourself
during shutdown — typically from a `lifespan=` callable you pass to `FastMCP`,
from an ASGI shutdown handler, or via `atexit`:

```python
import contextlib
from fastmcp import FastMCP


@contextlib.asynccontextmanager
async def lifespan(app: FastMCP):
try:
yield
finally:
bootstrapper.teardown()


bootstrapper_config = FastMcpConfig(
service_name="microservice",
application=FastMCP(lifespan=lifespan),
)
bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config)
application = bootstrapper.bootstrap()
```

Read more about available configuration options [here](../../../introduction/configuration):
````

Expand Down
Loading
Loading