Skip to content

Commit 674d7b4

Browse files
author
Jay Hemnani
committed
fix: add boolean string coercion for FastMCP tool parameters
Some LLM clients incorrectly serialize booleans as strings ("false" instead of false in JSON). The existing pre_parse_json method was supposed to handle this via json.loads, but boolean values were being skipped because bool is a subclass of int in Python, and the skip condition isinstance(pre_parsed, str | int | float) matches booleans. This fix adds explicit case-insensitive boolean string coercion before the generic JSON parsing logic, properly converting "true"/"false" strings to Python True/False when the parameter annotation is bool. Fixes #1843
1 parent 6b69f63 commit 674d7b4

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

src/mcp/server/fastmcp/utilities/func_metadata.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,22 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
156156
continue
157157

158158
field_info = key_to_field_info[data_key]
159+
160+
# Handle boolean string coercion (case-insensitive)
161+
# Some LLM clients incorrectly serialize booleans as strings ("true"/"false")
162+
# We need to handle this before the generic JSON parsing below, because
163+
# json.loads("false") returns False, but isinstance(False, int) is True
164+
# (since bool is a subclass of int in Python), causing it to be skipped.
165+
if isinstance(data_value, str) and field_info.annotation is bool:
166+
lower_value = data_value.lower()
167+
if lower_value == "true":
168+
new_data[data_key] = True
169+
continue
170+
elif lower_value == "false":
171+
new_data[data_key] = False
172+
continue
173+
# If not "true"/"false", fall through to existing logic
174+
159175
if isinstance(data_value, str) and field_info.annotation is not str:
160176
try:
161177
pre_parsed = json.loads(data_value)

tests/server/fastmcp/test_func_metadata.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,3 +1202,123 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc
12021202

12031203
assert meta.output_schema is not None
12041204
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
1205+
1206+
1207+
def test_bool_string_coercion_lowercase():
1208+
"""Test that string 'false'/'true' are coerced to Python booleans.
1209+
1210+
This tests the fix for issue #1843: LLM clients sometimes serialize
1211+
booleans as strings ("false" instead of false in JSON), and these
1212+
need to be properly coerced to Python booleans.
1213+
"""
1214+
1215+
def my_tool(flag: bool = True) -> str: # pragma: no cover
1216+
return f"flag is {flag}"
1217+
1218+
meta = func_metadata(my_tool)
1219+
1220+
# Test lowercase
1221+
result = meta.pre_parse_json({"flag": "false"})
1222+
assert result["flag"] is False
1223+
1224+
result = meta.pre_parse_json({"flag": "true"})
1225+
assert result["flag"] is True
1226+
1227+
1228+
def test_bool_string_coercion_case_insensitive():
1229+
"""Test case-insensitive boolean coercion."""
1230+
1231+
def my_tool(flag: bool) -> str: # pragma: no cover
1232+
return str(flag)
1233+
1234+
meta = func_metadata(my_tool)
1235+
1236+
# Test various case combinations for true
1237+
for true_val in ["true", "True", "TRUE", "tRuE"]:
1238+
result = meta.pre_parse_json({"flag": true_val})
1239+
assert result["flag"] is True, f"Expected True for {true_val!r}"
1240+
1241+
# Test various case combinations for false
1242+
for false_val in ["false", "False", "FALSE", "fAlSe"]:
1243+
result = meta.pre_parse_json({"flag": false_val})
1244+
assert result["flag"] is False, f"Expected False for {false_val!r}"
1245+
1246+
1247+
def test_bool_native_values_unchanged():
1248+
"""Test that native boolean values pass through unchanged."""
1249+
1250+
def my_tool(flag: bool) -> str: # pragma: no cover
1251+
return str(flag)
1252+
1253+
meta = func_metadata(my_tool)
1254+
1255+
# Native booleans should pass through
1256+
result = meta.pre_parse_json({"flag": True})
1257+
assert result["flag"] is True
1258+
1259+
result = meta.pre_parse_json({"flag": False})
1260+
assert result["flag"] is False
1261+
1262+
1263+
def test_bool_string_coercion_non_boolean_strings():
1264+
"""Test that non-boolean strings for bool params fall through to Pydantic validation."""
1265+
1266+
def my_tool(flag: bool) -> str: # pragma: no cover
1267+
return str(flag)
1268+
1269+
meta = func_metadata(my_tool)
1270+
1271+
# Non-boolean strings should not be modified by pre_parse_json
1272+
# (Pydantic validation will handle/reject them later)
1273+
result = meta.pre_parse_json({"flag": "yes"})
1274+
assert result["flag"] == "yes"
1275+
1276+
result = meta.pre_parse_json({"flag": "1"})
1277+
assert result["flag"] == "1"
1278+
1279+
result = meta.pre_parse_json({"flag": "no"})
1280+
assert result["flag"] == "no"
1281+
1282+
1283+
@pytest.mark.anyio
1284+
async def test_bool_string_coercion_runtime_validation():
1285+
"""Test that boolean string coercion works in full runtime validation."""
1286+
1287+
def tool_with_bool(enabled: bool, name: str) -> str: # pragma: no cover
1288+
assert isinstance(enabled, bool), f"Expected bool, got {type(enabled)}"
1289+
return f"enabled={enabled}, name={name}"
1290+
1291+
meta = func_metadata(tool_with_bool)
1292+
1293+
# Test with string "false" - should be coerced to Python False
1294+
result = await meta.call_fn_with_arg_validation(
1295+
tool_with_bool,
1296+
fn_is_async=False,
1297+
arguments_to_validate={"enabled": "false", "name": "test"},
1298+
arguments_to_pass_directly=None,
1299+
)
1300+
assert result == "enabled=False, name=test"
1301+
1302+
# Test with string "True" (capitalized) - should be coerced to Python True
1303+
result = await meta.call_fn_with_arg_validation(
1304+
tool_with_bool,
1305+
fn_is_async=False,
1306+
arguments_to_validate={"enabled": "True", "name": "test"},
1307+
arguments_to_pass_directly=None,
1308+
)
1309+
assert result == "enabled=True, name=test"
1310+
1311+
1312+
def test_bool_string_coercion_does_not_affect_other_types():
1313+
"""Test that boolean string coercion only applies to bool-annotated params."""
1314+
1315+
def my_tool(text: str, count: int, flag: bool) -> str: # pragma: no cover
1316+
return f"{text}, {count}, {flag}"
1317+
1318+
meta = func_metadata(my_tool)
1319+
1320+
# "false" for a str param should remain a string
1321+
result = meta.pre_parse_json({"text": "false", "count": 5, "flag": "true"})
1322+
assert result["text"] == "false" # Unchanged (str annotation)
1323+
assert result["count"] == 5 # Unchanged (int annotation)
1324+
assert result["flag"] is True # Coerced (bool annotation)

0 commit comments

Comments
 (0)