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.
This commit is contained in:
Jeremiah Lowin 2026-07-06 11:47:27 -04:00
commit d9659453f4
No known key found for this signature in database
3 changed files with 60 additions and 25 deletions

View file

@ -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")

View file

@ -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(

View file

@ -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: