Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/mcp/server/fastmcp/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
continue

field_info = key_to_field_info[data_key]

# Handle boolean string coercion (case-insensitive)
# Some LLM clients incorrectly serialize booleans as strings ("true"/"false")
# We need to handle this before the generic JSON parsing below, because
# json.loads("false") returns False, but isinstance(False, int) is True
# (since bool is a subclass of int in Python), causing it to be skipped.
if isinstance(data_value, str) and field_info.annotation is bool:
lower_value = data_value.lower()
if lower_value == "true":
new_data[data_key] = True
continue
elif lower_value == "false":
new_data[data_key] = False
continue
# If not "true"/"false", fall through to existing logic

if isinstance(data_value, str) and field_info.annotation is not str:
try:
pre_parsed = json.loads(data_value)
Expand Down
120 changes: 120 additions & 0 deletions tests/server/fastmcp/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1202,3 +1202,123 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc

assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}


def test_bool_string_coercion_lowercase():
"""Test that string 'false'/'true' are coerced to Python booleans.

This tests the fix for issue #1843: LLM clients sometimes serialize
booleans as strings ("false" instead of false in JSON), and these
need to be properly coerced to Python booleans.
"""

def my_tool(flag: bool = True) -> str: # pragma: no cover
return f"flag is {flag}"

meta = func_metadata(my_tool)

# Test lowercase
result = meta.pre_parse_json({"flag": "false"})
assert result["flag"] is False

result = meta.pre_parse_json({"flag": "true"})
assert result["flag"] is True


def test_bool_string_coercion_case_insensitive():
"""Test case-insensitive boolean coercion."""

def my_tool(flag: bool) -> str: # pragma: no cover
return str(flag)

meta = func_metadata(my_tool)

# Test various case combinations for true
for true_val in ["true", "True", "TRUE", "tRuE"]:
result = meta.pre_parse_json({"flag": true_val})
assert result["flag"] is True, f"Expected True for {true_val!r}"

# Test various case combinations for false
for false_val in ["false", "False", "FALSE", "fAlSe"]:
result = meta.pre_parse_json({"flag": false_val})
assert result["flag"] is False, f"Expected False for {false_val!r}"


def test_bool_native_values_unchanged():
"""Test that native boolean values pass through unchanged."""

def my_tool(flag: bool) -> str: # pragma: no cover
return str(flag)

meta = func_metadata(my_tool)

# Native booleans should pass through
result = meta.pre_parse_json({"flag": True})
assert result["flag"] is True

result = meta.pre_parse_json({"flag": False})
assert result["flag"] is False


def test_bool_string_coercion_non_boolean_strings():
"""Test that non-boolean strings for bool params fall through to Pydantic validation."""

def my_tool(flag: bool) -> str: # pragma: no cover
return str(flag)

meta = func_metadata(my_tool)

# Non-boolean strings should not be modified by pre_parse_json
# (Pydantic validation will handle/reject them later)
result = meta.pre_parse_json({"flag": "yes"})
assert result["flag"] == "yes"

result = meta.pre_parse_json({"flag": "1"})
assert result["flag"] == "1"

result = meta.pre_parse_json({"flag": "no"})
assert result["flag"] == "no"


@pytest.mark.anyio
async def test_bool_string_coercion_runtime_validation():
"""Test that boolean string coercion works in full runtime validation."""

def tool_with_bool(enabled: bool, name: str) -> str: # pragma: no cover
assert isinstance(enabled, bool), f"Expected bool, got {type(enabled)}"
return f"enabled={enabled}, name={name}"

meta = func_metadata(tool_with_bool)

# Test with string "false" - should be coerced to Python False
result = await meta.call_fn_with_arg_validation(
tool_with_bool,
fn_is_async=False,
arguments_to_validate={"enabled": "false", "name": "test"},
arguments_to_pass_directly=None,
)
assert result == "enabled=False, name=test"

# Test with string "True" (capitalized) - should be coerced to Python True
result = await meta.call_fn_with_arg_validation(
tool_with_bool,
fn_is_async=False,
arguments_to_validate={"enabled": "True", "name": "test"},
arguments_to_pass_directly=None,
)
assert result == "enabled=True, name=test"


def test_bool_string_coercion_does_not_affect_other_types():
"""Test that boolean string coercion only applies to bool-annotated params."""

def my_tool(text: str, count: int, flag: bool) -> str: # pragma: no cover
return f"{text}, {count}, {flag}"

meta = func_metadata(my_tool)

# "false" for a str param should remain a string
result = meta.pre_parse_json({"text": "false", "count": 5, "flag": "true"})
assert result["text"] == "false" # Unchanged (str annotation)
assert result["count"] == 5 # Unchanged (int annotation)
assert result["flag"] is True # Coerced (bool annotation)
Loading