From c51b302df964303ba481af09afd29d9e9fb907f1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 16:30:24 +0300 Subject: [PATCH 1/3] feat: make the FastMCP access log opt-in Closes #240 --- docs/integrations/fastmcp.md | 22 +++++++- docs/introduction/configuration.md | 10 ++++ .../bootstrappers/fastmcp_bootstrapper.py | 16 +++++- tests/test_fastmcp_bootstrap.py | 54 +++++++++++++++---- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 2fce791..407afac 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. + +`logging_turn_off_middleware` still works and keeps the behaviour its setter asked for, but it is +superseded and warns: `logging_turn_off_middleware=False` turns the middleware on, `True` leaves it +off. + +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..93e32fd 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -255,6 +255,16 @@ 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`). +- `logging_turn_off_middleware` - superseded by the field above and warns when set. `False` turns the + middleware on, `True` leaves it off. + +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..8ec8108 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -5,6 +5,7 @@ from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper +from lite_bootstrap.helpers.warn import warn_at_caller 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 @@ -81,7 +82,8 @@ 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 + logging_turn_off_middleware: bool | None = None @dataclasses.dataclass(kw_only=True) @@ -132,10 +134,20 @@ class FastMcpLoggingInstrument(LoggingInstrument): def bootstrap(self) -> None: super().bootstrap() - if self.bootstrap_config.logging_turn_off_middleware: + if not self._middleware_enabled(): return self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) + def _middleware_enabled(self) -> bool: + config = self.bootstrap_config + if config.logging_turn_off_middleware is None: + return config.fastmcp_logging_middleware_enabled + warn_at_caller( + "logging_turn_off_middleware is superseded by fastmcp_logging_middleware_enabled, " + "which is False by default; set fastmcp_logging_middleware_enabled=True to log messages." + ) + return not config.logging_turn_off_middleware + class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): __slots__ = "bootstrap_config", "instruments" diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 0cdd9d4..16f2895 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -13,7 +13,11 @@ 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, + warning_source_files, +) def test_fastmcp_config_default_application() -> None: @@ -221,24 +225,52 @@ 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: +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 + + +@pytest.mark.parametrize( + ("turn_off", "expected_count"), + [(False, 1), (True, 0)], + ids=["turn_off_false_still_mounts", "turn_off_true_does_not_mount"], +) +def test_fastmcp_logging_turn_off_middleware_still_works(turn_off: bool, expected_count: int) -> None: + """The superseded flag keeps the behaviour its setter asked for, and says it is superseded.""" + config = _make_test_config(logging_turn_off_middleware=turn_off) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + mounted = _mounted_logging_middleware(config) + + assert [str(one.message) for one in caught if "logging_turn_off_middleware" in str(one.message)] + assert len(mounted) == expected_count + + +def test_fastmcp_logging_turn_off_middleware_warning_names_the_caller() -> None: + """INVARIANT: the superseded-flag warning is attributed to the user's own frame. + + Same reason as the config-validation warnings in test_config_cascade.py: every frame between + the warn call and the user is lite-bootstrap's, and the distance is not a constant. + """ 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() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _mounted_logging_middleware(config) + + assert warning_source_files(caught, UserWarning) == [__file__] @pytest.mark.parametrize( From 86e588e0a36e9a9306423a0217bb8adccf9bfb60 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 16:36:05 +0300 Subject: [PATCH 2/3] review: warn at config construction, per branch, and on the contradiction --- docs/integrations/fastmcp.md | 6 ++-- docs/introduction/configuration.md | 2 +- .../bootstrappers/fastmcp_bootstrapper.py | 36 +++++++++++++------ tests/test_fastmcp_bootstrap.py | 31 ++++++++++++---- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 407afac..439a72d 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -62,9 +62,9 @@ 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. -`logging_turn_off_middleware` still works and keeps the behaviour its setter asked for, but it is -superseded and warns: `logging_turn_off_middleware=False` turns the middleware on, `True` leaves it -off. +`logging_turn_off_middleware` still works but is superseded and warns: +`logging_turn_off_middleware=False` turns the middleware on, `True` leaves it off. If you set both +fields, `fastmcp_logging_middleware_enabled` wins and the superseded one is reported as ignored. Set `health_checks_enabled=False` to omit the health route. diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 93e32fd..be2fb62 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -261,7 +261,7 @@ The per-MCP-message access log is **off by default**: - `fastmcp_logging_middleware_enabled` - turn on the access log (default: `False`). - `logging_turn_off_middleware` - superseded by the field above and warns when set. `False` turns the - middleware on, `True` leaves it off. + middleware on, `True` leaves it off, and if both are set the field above wins. See [the FastMCP integration guide](../integrations/fastmcp.md#logging) for what gets logged. diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 8ec8108..fe8a83b 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -85,6 +85,30 @@ class FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, Pyrosco fastmcp_logging_middleware_enabled: bool = False logging_turn_off_middleware: bool | None = None + def __post_init__(self) -> None: + # Not super(): the missing-dependency tests reload this module, which rebinds the global + # name, so `super(FastMcpConfig, self)` would not match the instance's own class. + HealthChecksConfig.__post_init__(self) + if self.logging_turn_off_middleware is None: + return + if self.fastmcp_logging_middleware_enabled: + warn_at_caller( + "logging_turn_off_middleware is ignored because fastmcp_logging_middleware_enabled " + "is set; drop logging_turn_off_middleware." + ) + return + if self.logging_turn_off_middleware: + warn_at_caller( + "logging_turn_off_middleware is superseded by fastmcp_logging_middleware_enabled, " + "which is False by default; drop logging_turn_off_middleware." + ) + return + warn_at_caller( + "logging_turn_off_middleware is superseded by fastmcp_logging_middleware_enabled; " + "set fastmcp_logging_middleware_enabled=True instead." + ) + object.__setattr__(self, "fastmcp_logging_middleware_enabled", True) + @dataclasses.dataclass(kw_only=True) class FastMcpHealthChecksInstrument(HealthChecksInstrument): @@ -134,20 +158,10 @@ class FastMcpLoggingInstrument(LoggingInstrument): def bootstrap(self) -> None: super().bootstrap() - if not self._middleware_enabled(): + if not self.bootstrap_config.fastmcp_logging_middleware_enabled: return self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) - def _middleware_enabled(self) -> bool: - config = self.bootstrap_config - if config.logging_turn_off_middleware is None: - return config.fastmcp_logging_middleware_enabled - warn_at_caller( - "logging_turn_off_middleware is superseded by fastmcp_logging_middleware_enabled, " - "which is False by default; set fastmcp_logging_middleware_enabled=True to log messages." - ) - return not config.logging_turn_off_middleware - class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): __slots__ = "bootstrap_config", "instruments" diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 16f2895..acf483b 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -249,14 +249,14 @@ def test_fastmcp_logging_middleware_is_mounted_when_enabled() -> None: ids=["turn_off_false_still_mounts", "turn_off_true_does_not_mount"], ) def test_fastmcp_logging_turn_off_middleware_still_works(turn_off: bool, expected_count: int) -> None: - """The superseded flag keeps the behaviour its setter asked for, and says it is superseded.""" - config = _make_test_config(logging_turn_off_middleware=turn_off) + """The superseded flag keeps the behaviour it was set for, and says that it is superseded.""" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - mounted = _mounted_logging_middleware(config) + config = _make_test_config(logging_turn_off_middleware=turn_off) assert [str(one.message) for one in caught if "logging_turn_off_middleware" in str(one.message)] - assert len(mounted) == expected_count + assert config.fastmcp_logging_middleware_enabled is (not turn_off) + assert len(_mounted_logging_middleware(config)) == expected_count def test_fastmcp_logging_turn_off_middleware_warning_names_the_caller() -> None: @@ -265,14 +265,33 @@ def test_fastmcp_logging_turn_off_middleware_warning_names_the_caller() -> None: Same reason as the config-validation warnings in test_config_cascade.py: every frame between the warn call and the user is lite-bootstrap's, and the distance is not a constant. """ - config = _make_test_config(logging_turn_off_middleware=True) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - _mounted_logging_middleware(config) + _make_test_config(logging_turn_off_middleware=True) assert warning_source_files(caught, UserWarning) == [__file__] +@pytest.mark.parametrize("overrides", [{}, {"fastmcp_logging_middleware_enabled": True}], ids=["default", "enabled"]) +def test_fastmcp_logging_warns_nothing_without_the_superseded_flag(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] == [] + + +def test_fastmcp_logging_explicit_flag_wins_over_the_superseded_one() -> None: + """Setting both keeps the current field and says the superseded one is ignored.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + config = _make_test_config(fastmcp_logging_middleware_enabled=True, logging_turn_off_middleware=True) + + assert [one for one in caught if "is ignored because" in str(one.message)] + assert config.fastmcp_logging_middleware_enabled is True + assert len(_mounted_logging_middleware(config)) == 1 + + @pytest.mark.parametrize( ("package_name", "extra_config"), [ From dab049c46a3dac500000fc0ee1ac40c90e67e5e7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 16:42:24 +0300 Subject: [PATCH 3/3] feat: remove logging_turn_off_middleware instead of shimming it --- docs/integrations/fastmcp.md | 6 +-- docs/introduction/configuration.md | 5 +-- .../bootstrappers/fastmcp_bootstrapper.py | 26 ----------- tests/test_fastmcp_bootstrap.py | 43 +++---------------- 4 files changed, 11 insertions(+), 69 deletions(-) diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 439a72d..0233752 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -62,9 +62,9 @@ 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. -`logging_turn_off_middleware` still works but is superseded and warns: -`logging_turn_off_middleware=False` turns the middleware on, `True` leaves it off. If you set both -fields, `fastmcp_logging_middleware_enabled` wins and the superseded one is reported as ignored. +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. diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index be2fb62..2fa505e 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -259,9 +259,8 @@ it defaults to off. The per-MCP-message access log is **off by default**: -- `fastmcp_logging_middleware_enabled` - turn on the access log (default: `False`). -- `logging_turn_off_middleware` - superseded by the field above and warns when set. `False` turns the - middleware on, `True` leaves it off, and if both are set the field above wins. +- `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. diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index fe8a83b..21fe3b3 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -5,7 +5,6 @@ from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper -from lite_bootstrap.helpers.warn import warn_at_caller 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 @@ -83,31 +82,6 @@ async def on_message( class FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig): application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp) fastmcp_logging_middleware_enabled: bool = False - logging_turn_off_middleware: bool | None = None - - def __post_init__(self) -> None: - # Not super(): the missing-dependency tests reload this module, which rebinds the global - # name, so `super(FastMcpConfig, self)` would not match the instance's own class. - HealthChecksConfig.__post_init__(self) - if self.logging_turn_off_middleware is None: - return - if self.fastmcp_logging_middleware_enabled: - warn_at_caller( - "logging_turn_off_middleware is ignored because fastmcp_logging_middleware_enabled " - "is set; drop logging_turn_off_middleware." - ) - return - if self.logging_turn_off_middleware: - warn_at_caller( - "logging_turn_off_middleware is superseded by fastmcp_logging_middleware_enabled, " - "which is False by default; drop logging_turn_off_middleware." - ) - return - warn_at_caller( - "logging_turn_off_middleware is superseded by fastmcp_logging_middleware_enabled; " - "set fastmcp_logging_middleware_enabled=True instead." - ) - object.__setattr__(self, "fastmcp_logging_middleware_enabled", True) @dataclasses.dataclass(kw_only=True) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index acf483b..0c9ce42 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -16,7 +16,6 @@ from tests.conftest import ( emulate_package_missing, emulate_package_missing_with_module_reload, - warning_source_files, ) @@ -243,37 +242,18 @@ def test_fastmcp_logging_middleware_is_mounted_when_enabled() -> None: assert len(_mounted_logging_middleware(config)) == 1 -@pytest.mark.parametrize( - ("turn_off", "expected_count"), - [(False, 1), (True, 0)], - ids=["turn_off_false_still_mounts", "turn_off_true_does_not_mount"], -) -def test_fastmcp_logging_turn_off_middleware_still_works(turn_off: bool, expected_count: int) -> None: - """The superseded flag keeps the behaviour it was set for, and says that it is superseded.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - config = _make_test_config(logging_turn_off_middleware=turn_off) +def test_fastmcp_config_rejects_the_removed_turn_off_field() -> None: + """The superseded name is gone: setting it fails at construction rather than silently. - assert [str(one.message) for one in caught if "logging_turn_off_middleware" in str(one.message)] - assert config.fastmcp_logging_middleware_enabled is (not turn_off) - assert len(_mounted_logging_middleware(config)) == expected_count - - -def test_fastmcp_logging_turn_off_middleware_warning_names_the_caller() -> None: - """INVARIANT: the superseded-flag warning is attributed to the user's own frame. - - Same reason as the config-validation warnings in test_config_cascade.py: every frame between - the warn call and the user is lite-bootstrap's, and the distance is not a constant. + 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 warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") + with pytest.raises(TypeError, match="logging_turn_off_middleware"): _make_test_config(logging_turn_off_middleware=True) - assert warning_source_files(caught, UserWarning) == [__file__] - @pytest.mark.parametrize("overrides", [{}, {"fastmcp_logging_middleware_enabled": True}], ids=["default", "enabled"]) -def test_fastmcp_logging_warns_nothing_without_the_superseded_flag(overrides: dict[str, bool]) -> None: +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) @@ -281,17 +261,6 @@ def test_fastmcp_logging_warns_nothing_without_the_superseded_flag(overrides: di assert [str(one.message) for one in caught] == [] -def test_fastmcp_logging_explicit_flag_wins_over_the_superseded_one() -> None: - """Setting both keeps the current field and says the superseded one is ignored.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - config = _make_test_config(fastmcp_logging_middleware_enabled=True, logging_turn_off_middleware=True) - - assert [one for one in caught if "is ignored because" in str(one.message)] - assert config.fastmcp_logging_middleware_enabled is True - assert len(_mounted_logging_middleware(config)) == 1 - - @pytest.mark.parametrize( ("package_name", "extra_config"), [