diff --git a/mcp_clickhouse/mcp_server.py b/mcp_clickhouse/mcp_server.py index c8a12243..0ae309fe 100644 --- a/mcp_clickhouse/mcp_server.py +++ b/mcp_clickhouse/mcp_server.py @@ -188,8 +188,29 @@ def result_to_column(query_columns, result) -> List[Column]: return [Column(**dict(zip(query_columns, row))) for row in result] +# Integers outside JavaScript's safe range lose precision once a JSON consumer +# (browsers, Node-based MCP clients, ...) parses them as IEEE-754 doubles, e.g. +# UInt64 1875924584784080993 -> 1875924584784081000. Emit such values as strings +# so the exact value survives; smaller ints stay numeric. +JS_MAX_SAFE_INTEGER = (1 << 53) - 1 + + +def _json_safe(obj: Any) -> Any: + """Recursively stringify integers that exceed JavaScript's safe integer range.""" + # bool is a subclass of int but is always JSON-safe, so leave it numeric. + if isinstance(obj, bool): + return obj + if isinstance(obj, int): + return str(obj) if abs(obj) > JS_MAX_SAFE_INTEGER else obj + if isinstance(obj, dict): + return {key: _json_safe(value) for key, value in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(item) for item in obj] + return obj + + def _serialize_tool_result(obj: Any) -> str: - return json.dumps(obj, default=str) + return json.dumps(_json_safe(obj), default=str) def list_databases() -> str: diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 00000000..cc544a4a --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,62 @@ +"""Unit tests for JSON serialization of tool results. + +These are pure-function tests and do not require a ClickHouse connection. +""" + +import json + +from mcp_clickhouse.mcp_server import ( + JS_MAX_SAFE_INTEGER, + _json_safe, + _serialize_tool_result, +) + + +def test_large_uint64_is_stringified(): + # Value from the bug report: it would otherwise round to ...081000. + value = 1875924584784080993 + result = json.loads(_serialize_tool_result({"columns": ["id"], "rows": [[value]]})) + assert result["rows"][0][0] == str(value) + + +def test_large_negative_int_is_stringified(): + value = -(1 << 63) + assert _json_safe(value) == str(value) + + +def test_boundary_values_stay_numeric(): + # The largest safe integer and everything below it must remain a JSON number. + assert _json_safe(JS_MAX_SAFE_INTEGER) == JS_MAX_SAFE_INTEGER + assert _json_safe(-JS_MAX_SAFE_INTEGER) == -JS_MAX_SAFE_INTEGER + assert _json_safe(0) == 0 + assert _json_safe(42) == 42 + # One past the boundary flips to a string. + assert _json_safe(JS_MAX_SAFE_INTEGER + 1) == str(JS_MAX_SAFE_INTEGER + 1) + + +def test_booleans_stay_boolean(): + # bool is an int subclass; it must never be stringified. + assert _json_safe(True) is True + assert _json_safe(False) is False + + +def test_nested_structures_are_walked(): + payload = { + "rows": [ + (1875924584784080993, "web-01", [9999999999999999999, 7]), + ], + "meta": {"count": 2, "huge": 12345678901234567890}, + } + result = json.loads(_serialize_tool_result(payload)) + assert result == { + "rows": [["1875924584784080993", "web-01", ["9999999999999999999", 7]]], + "meta": {"count": 2, "huge": "12345678901234567890"}, + } + + +def test_non_int_types_still_serialize_via_default(): + # Types json can't encode natively fall through to json.dumps(default=str). + from decimal import Decimal + + result = json.loads(_serialize_tool_result({"price": Decimal("1.5")})) + assert result["price"] == "1.5"