diff --git a/fastmcp_slim/fastmcp/tools/tool_transform.py b/fastmcp_slim/fastmcp/tools/tool_transform.py index 910b89262..effb5386b 100644 --- a/fastmcp_slim/fastmcp/tools/tool_transform.py +++ b/fastmcp_slim/fastmcp/tools/tool_transform.py @@ -707,7 +707,8 @@ class TransformedTool(Tool): schema = { "type": "object", "properties": new_props, - "required": list(new_required), + # Iterate props (not the set) for deterministic ordering + "required": [p for p in new_props if p in new_required], "additionalProperties": False, } @@ -899,7 +900,11 @@ class TransformedTool(Tool): result = { "type": "object", "properties": merged_props, - "required": list(final_required), + # Iterate props (not the set) for deterministic ordering; keep any + # required names not present in properties (sorted) rather than + # silently dropping them. + "required": [p for p in merged_props if p in final_required] + + sorted(final_required - set(merged_props)), "additionalProperties": False, } diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index f9e50df72..2cde40b7f 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -41,6 +41,23 @@ def test_tool_from_tool_no_change(add_tool): assert new_tool.description == add_tool.description +def test_transformed_tool_required_order_is_deterministic(): + """`required` must follow property order, not set iteration order. + + Set iteration order varies with PYTHONHASHSEED, which broke snapshot + tests of tools/list output across processes. + """ + + def fn(alpha: int, beta: str, gamma: float, delta: bool, epsilon: int) -> str: + return "x" + + base = Tool.from_function(fn) + transformed = Tool.from_tool(base, transform_args={"alpha": ArgTransform(name="a")}) + props = list(transformed.parameters["properties"]) + assert transformed.parameters["required"] == props + assert props == ["a", "beta", "gamma", "delta", "epsilon"] + + def test_from_tool_accepts_decorated_function(): @tool def search(q: str, limit: int = 10) -> list[str]: