diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 2fce791..0233752 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -47,8 +47,26 @@ 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. +## Logging + +The per-MCP-message access log is **off by default**, matching FastAPI and Litestar. Turn it on +explicitly: + +```python +FastMcpConfig( + service_name="microservice", + fastmcp_logging_middleware_enabled=True, +) +``` + +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. + +This replaces `logging_turn_off_middleware`, which has been removed. Setting it now raises +`TypeError`: the default flipped from on to off, so a service that configured the old field has to +decide again rather than upgrade past the change unnoticed. + +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 diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index fe74989..2fa505e 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -255,6 +255,15 @@ FastAPI ships no access log of its own, so lite-bootstrap provides one. It is ** See [the FastAPI integration guide](../integrations/fastapi.md#logging) for what gets logged and why it defaults to off. +### Structlog FastMCP + +The per-MCP-message access log is **off by default**: + +- `fastmcp_logging_middleware_enabled` - turn on the access log (default: `False`). Replaces + `logging_turn_off_middleware`, which has been removed. + +See [the FastMCP integration guide](../integrations/fastmcp.md#logging) for what gets logged. + ### Structlog FastStream When using FastStream, the structlog logger is automatically injected into the broker so that all broker diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 6861656..21fe3b3 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -81,7 +81,7 @@ async def on_message( @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 + fastmcp_logging_middleware_enabled: bool = False @dataclasses.dataclass(kw_only=True) @@ -132,7 +132,7 @@ class FastMcpLoggingInstrument(LoggingInstrument): def bootstrap(self) -> None: super().bootstrap() - if self.bootstrap_config.logging_turn_off_middleware: + if not self.bootstrap_config.fastmcp_logging_middleware_enabled: return self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 0cdd9d4..0c9ce42 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -13,7 +13,10 @@ from lite_bootstrap import BootstrapperNotReadyError, FastMcpBootstrapper, FastMcpConfig from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware from lite_bootstrap.exceptions import ConfigurationError -from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload +from tests.conftest import ( + emulate_package_missing, + emulate_package_missing_with_module_reload, +) def test_fastmcp_config_default_application() -> None: @@ -221,24 +224,41 @@ def _find_mcp_logging_middleware(application: "FastMCP") -> list[FastMcpLoggingM 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() +def _mounted_logging_middleware(config: FastMcpConfig) -> list[FastMcpLoggingMiddleware]: bootstrapper = FastMcpBootstrapper(bootstrap_config=config) application = bootstrapper.bootstrap() try: - assert len(_find_mcp_logging_middleware(application)) == 1 + return _find_mcp_logging_middleware(application) 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() +def test_fastmcp_logging_middleware_is_not_mounted_by_default() -> None: + assert _mounted_logging_middleware(_make_test_config()) == [] + + +def test_fastmcp_logging_middleware_is_mounted_when_enabled() -> None: + config = _make_test_config(fastmcp_logging_middleware_enabled=True) + assert len(_mounted_logging_middleware(config)) == 1 + + +def test_fastmcp_config_rejects_the_removed_turn_off_field() -> None: + """The superseded name is gone: setting it fails at construction rather than silently. + + The default flipped, so a shim could not have been silent anyway, and a service that set the old + field has to re-decide rather than upgrade past the change without noticing. + """ + with pytest.raises(TypeError, match="logging_turn_off_middleware"): + _make_test_config(logging_turn_off_middleware=True) + + +@pytest.mark.parametrize("overrides", [{}, {"fastmcp_logging_middleware_enabled": True}], ids=["default", "enabled"]) +def test_fastmcp_logging_config_warns_nothing(overrides: dict[str, bool]) -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _make_test_config(**overrides) + + assert [str(one.message) for one in caught] == [] @pytest.mark.parametrize(