diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index b446de0..d2416d2 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -62,6 +62,11 @@ FastMcpConfig( Enabled, each message is logged with its `method`, `source` and `type`, plus `duration` in nanoseconds. A message that raises is logged at exception level and the exception is re-raised. +lite-bootstrap 2.0 requires fastmcp 4, where this log sees every message a client sends. That +includes notifications such as `notifications/initialized` and `notifications/cancelled`, the +`server/discover` request of a modern connection, and requests that fail before reaching a handler. +On fastmcp 3 it saw only routable requests. + Set `health_checks_enabled=False` to omit the health route. ## Sentry diff --git a/pyproject.toml b/pyproject.toml index 7a10ede..d27cfb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,11 +174,10 @@ faststream-all = [ "lite-bootstrap[sentry,otl,logging,faststream,faststream-metrics,pyroscope]" ] fastmcp = [ - # >=3.0 for fastmcp.server.providers.Provider and FastMCP.add_provider() - # (FastMcpBootstrapper registers its teardown through them -- - # docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md). - "fastmcp>=3.0", - # fastmcp 3.0 pulls py-key-value-aio, which requires typing-extensions>=4.15. + # >=4.0 so one middleware dispatch model is supported: from 4.0 the access log's on_message sees + # every inbound message, notifications included. <5 until 5.x is tested. + "fastmcp>=4.0,<5", + # fastmcp 4.0 pulls py-key-value-aio 0.4.4, which requires typing-extensions>=4.15. "typing-extensions>=4.15", ] fastmcp-otl = [ @@ -188,7 +187,7 @@ fastmcp-otl = [ fastmcp-metrics = [ "lite-bootstrap[fastmcp]", "prometheus-client>=0.20", - # >=8.0.1 to share the fastapi-metrics floor; it requires starlette>=1.0, which fastmcp 3.0 already pulls. + # >=8.0.1 to share the fastapi-metrics floor; it requires starlette>=1.0, which fastmcp 4.0 already pulls. "prometheus-fastapi-instrumentator>=8.0.1", ] fastmcp-all = [ diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 8a2301f..d9ace37 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -131,6 +131,25 @@ async def call_next(received: MiddlewareContext[typing.Any]) -> dict[str, str]: assert isinstance(call_kwargs.kwargs["duration"], int) +async def test_fastmcp_access_log_records_notifications(monkeypatch: pytest.MonkeyPatch) -> None: + fake_logger = MagicMock() + monkeypatch.setattr(fastmcp_bootstrapper, "fastmcp_access_logger", fake_logger) + bootstrapper = FastMcpBootstrapper(bootstrap_config=_make_test_config(logging_access_log_enabled=True)) + application = bootstrapper.bootstrap() + try: + async with Client(application, mode="legacy") as client: + await client.list_tools() + finally: + bootstrapper.teardown() + + logged = [(call.kwargs["mcp"]["method"], call.kwargs["mcp"]["type"]) for call in fake_logger.info.call_args_list] + assert logged == [ + ("initialize", "request"), + ("notifications/initialized", "notification"), + ("tools/list", "request"), + ] + + async def test_fastmcp_logging_middleware_logs_exception(monkeypatch: pytest.MonkeyPatch) -> None: fake_logger = MagicMock() monkeypatch.setattr(