From d9659453f49c4ee5c58f394ee93eb4f365a0429d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:47:27 -0400 Subject: [PATCH] Make mcp_camelcase_compat a runtime toggle Install the camelCase bridge properties unconditionally and gate each getter on the live setting: warn+return when enabled, raise AttributeError when disabled. Previously the bridge installed once at import and setting mcp_camelcase_compat = False afterward had no effect, contradicting the upgrade docs. --- fastmcp_slim/fastmcp/__init__.py | 9 +++--- fastmcp_slim/fastmcp/_compat.py | 22 +++++++++++-- tests/test_compat.py | 54 +++++++++++++++++++++----------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/fastmcp_slim/fastmcp/__init__.py b/fastmcp_slim/fastmcp/__init__.py index f551344ba..0e1c33bf0 100644 --- a/fastmcp_slim/fastmcp/__init__.py +++ b/fastmcp_slim/fastmcp/__init__.py @@ -30,11 +30,12 @@ if settings.log_enabled: ) # Install camelCase compatibility shims for MCP SDK v2's snake_case rename. -# Gated by the setting; patches only mcp_types model classes, no client chain. -if settings.mcp_camelcase_compat: - from fastmcp import _compat +# Installed unconditionally; each shim's getter checks the live +# `mcp_camelcase_compat` setting at read time, so the bridge can be toggled at +# runtime. Patches only mcp_types model classes, no client chain. +from fastmcp import _compat - _compat.install() +_compat.install() try: __version__ = _version("fastmcp-slim") diff --git a/fastmcp_slim/fastmcp/_compat.py b/fastmcp_slim/fastmcp/_compat.py index 38c30bd05..f5fcc7f81 100644 --- a/fastmcp_slim/fastmcp/_compat.py +++ b/fastmcp_slim/fastmcp/_compat.py @@ -9,8 +9,14 @@ This module installs warn-once `@property` shims that route a small set of documented camelCase reads to their snake_case attributes. Only fields users actually read (per the docs boundary inventory) are bridged; each read emits a single `FastMCPDeprecationWarning` per (class, name) and returns the correct -value. Installation is idempotent and gated by the `mcp_camelcase_compat` -setting. +value. Installation is idempotent. + +The properties are installed unconditionally, but each getter checks the live +`mcp_camelcase_compat` setting at read time: when the setting is enabled it +warns and returns the snake_case value; when disabled it raises `AttributeError` +exactly as if the property were never installed. This makes the setting a +genuine runtime toggle (`fastmcp.settings.mcp_camelcase_compat = False` after +import turns the bridge off) at negligible overhead. Guards ensure we never shadow a real upstream attribute: if a class already defines the camelCase name in its own `__dict__` or in its pydantic @@ -95,11 +101,21 @@ _installed = False def _make_property(cls_name: str, camel: str, snake: str) -> property: - """Build a warn-once property routing a camelCase read to a snake attr.""" + """Build a warn-once property routing a camelCase read to a snake attr. + + The getter reads the live `mcp_camelcase_compat` setting on every access: if + the bridge is disabled it raises `AttributeError` (matching the message + Python raises for a genuinely missing attribute) so the shim is transparent; + if enabled it warns once and returns the snake_case value. + """ warned = False def getter(self: object) -> object: nonlocal warned + import fastmcp + + if not fastmcp.settings.mcp_camelcase_compat: + raise AttributeError(f"{cls_name!r} object has no attribute {camel!r}") if not warned: warned = True warnings.warn( diff --git a/tests/test_compat.py b/tests/test_compat.py index 330fc90af..bb2c1233a 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -7,6 +7,7 @@ import mcp_types import pytest from mcp import MCPError as SDKMCPError +import fastmcp import fastmcp._compat as _compat from fastmcp import Client, FastMCP from fastmcp.client.transports import FastMCPTransport @@ -159,24 +160,41 @@ class TestGuards: class TestSettingOff: - def test_setting_off_no_bridge(self, monkeypatch): - # Simulate a fresh import with the setting disabled: strip the installed - # properties and confirm the camelCase read raises AttributeError. - installed = {} - for cls, mapping in _compat._ALIASES.items(): - for camel in mapping: - attr = cls.__dict__.get(camel) - if isinstance(attr, property): - installed.setdefault(cls, []).append(camel) - delattr(cls, camel) - try: - tool = mcp_types.Tool(name="t", input_schema={"type": "object"}) - with pytest.raises(AttributeError): - _ = tool.inputSchema # ty: ignore[unresolved-attribute] - finally: - _compat._installed = False - _compat.install() - assert installed # sanity: something was actually removed + def test_setting_off_raises_attribute_error(self, monkeypatch): + # The property stays installed, but with the setting disabled the getter + # raises AttributeError as if the camelCase name never existed. + monkeypatch.setattr(fastmcp.settings, "mcp_camelcase_compat", False) + tool = mcp_types.Tool(name="t", input_schema={"type": "object"}) + with pytest.raises(AttributeError): + _ = tool.inputSchema # ty: ignore[unresolved-attribute] + + def test_setting_off_attribute_error_message(self, monkeypatch): + monkeypatch.setattr(fastmcp.settings, "mcp_camelcase_compat", False) + tool = mcp_types.Tool(name="t", input_schema={"type": "object"}) + with pytest.raises( + AttributeError, + match=r"'Tool' object has no attribute 'inputSchema'", + ): + _ = tool.inputSchema # ty: ignore[unresolved-attribute] + + def test_runtime_toggle_on_off_on(self, monkeypatch): + # The bridge honours the live setting on every read: on -> off -> on. + tool = mcp_types.Tool(name="t", input_schema={"type": "object"}) + + # On (default): bridged read works and warns. + with pytest.warns(FastMCPDeprecationWarning): + assert tool.inputSchema == {"type": "object"} # ty: ignore[unresolved-attribute] + + # Off: same attribute now raises. + monkeypatch.setattr(fastmcp.settings, "mcp_camelcase_compat", False) + with pytest.raises(AttributeError): + _ = tool.inputSchema # ty: ignore[unresolved-attribute] + + # Back on: resolves again (warn-once may have fired already, so ignore). + monkeypatch.setattr(fastmcp.settings, "mcp_camelcase_compat", True) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert tool.inputSchema == {"type": "object"} # ty: ignore[unresolved-attribute] class TestExceptionAlias: