From 52700a4e221d5aa45ae6c632cb3cb74643930723 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Jun 2025 12:38:48 -0400 Subject: [PATCH] Use `hide` instead of `drop` --- src/fastmcp/tools/tool_transform.py | 47 +++++++++++---- tests/tools/test_tool_transform.py | 91 ++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 968f84334..c89367008 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -23,7 +23,7 @@ new_tool = Tool.from_tool( original_tool, transform_args={ "old_name": ArgTransform(name="new_name", description="Updated desc"), - "unwanted": ArgTransform(drop=True), + "hidden_param": ArgTransform(hide=True, default="constant_value"), "simple": "renamed" } ) @@ -73,7 +73,7 @@ new_tool = Tool.from_tool( - `name`: Rename the argument - `description`: Change the description - `default`: Add/change default value -- `drop=True`: Remove the argument entirely +- `hide=True`: Hide the argument from clients (pass constant value to parent) ## Common Patterns @@ -91,10 +91,13 @@ enhanced = Tool.from_tool( # No transform_args = all parent args pass through unchanged ) -# Drop specific arguments +# Hide specific arguments with constant values simplified = Tool.from_tool( complex_tool, - transform_args={"complex_config": None} # Drops only this arg + transform_args={ + "api_key": ArgTransform(hide=True, default="secret_key"), # Hidden constant + "debug": ArgTransform(hide=True) # Hidden, uses parent's default + } ) ``` """ @@ -188,14 +191,14 @@ class ArgTransform: This class allows fine-grained control over how individual arguments are transformed when creating a new tool from an existing one. You can rename arguments, change their - descriptions, add default values, or drop them entirely. + descriptions, add default values, or hide them from clients while passing constants. Attributes: name: New name for the argument. Use None to keep original name, or ... for no change. description: New description for the argument. Use None to remove description, or ... for no change. default: New default value for the argument. Use ... for no change. type: New type for the argument. Use ... for no change. - drop: If True, remove this argument from the transformed tool's schema. + hide: If True, hide this argument from clients but pass a constant value to parent. Examples: # Rename argument 'old_name' to 'new_name' @@ -210,8 +213,11 @@ class ArgTransform: # Change the type ArgTransform(type=str) - # Drop the argument entirely - ArgTransform(drop=True) + # Hide the argument entirely from clients + ArgTransform(hide=True) + + # Hide argument but pass a constant value to parent + ArgTransform(hide=True, default="constant_value") # Combine multiple transformations ArgTransform(name="new_name", description="New desc", default=None, type=int) @@ -221,7 +227,7 @@ class ArgTransform: description: str | None | EllipsisType = ... default: Any | EllipsisType = ... type: Any | EllipsisType = ... - drop: bool = False + hide: bool = False class TransformedTool(Tool): @@ -393,7 +399,7 @@ class TransformedTool(Tool): for old_name, transform in transform_args.items(): if isinstance(transform, str): new_names.append(transform) - elif isinstance(transform, ArgTransform) and not transform.drop: + elif isinstance(transform, ArgTransform) and not transform.hide: if transform.name is not ... and transform.name is not None: new_names.append(transform.name) else: @@ -456,6 +462,7 @@ class TransformedTool(Tool): new_props = {} new_required = set() new_to_old = {} + hidden_defaults = {} # Track hidden parameters with constant values for old_name, old_schema in parent_props.items(): # Check if parameter is in transform_args @@ -464,6 +471,21 @@ class TransformedTool(Tool): else: transform = ... # Default behavior - pass through + # Handle hidden parameters with defaults + if isinstance(transform, ArgTransform) and transform.hide: + # Validate that hidden parameters without user defaults have parent defaults + if transform.default is ... and old_name in parent_required: + raise ValueError( + f"Hidden parameter '{old_name}' has no default value in parent tool " + f"and no default provided in ArgTransform. Either provide a default " + f"in ArgTransform or don't hide required parameters." + ) + if transform.default is not ...: + # Hidden parameter with a constant value + hidden_defaults[old_name] = transform.default + # Skip adding to schema (not exposed to clients) + continue + transform_result = cls._apply_single_transform( old_name, old_schema, @@ -509,6 +531,9 @@ class TransformedTool(Tool): old_name = new_to_old.get(new_name, new_name) parent_args[old_name] = value + # Add hidden defaults (constant values for hidden parameters) + parent_args.update(hidden_defaults) + return await parent_tool.run(parent_args) return schema, _forward @@ -548,7 +573,7 @@ class TransformedTool(Tool): return transform, old_schema.copy(), is_required if isinstance(transform, ArgTransform): - if transform.drop: + if transform.hide: return None if transform.name is not ...: diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index 6217b441e..569c40abb 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -93,7 +93,7 @@ async def test_tool_drop_arg_with_none(add_tool): async def test_tool_drop_arg_with_arg_transform(add_tool): new_tool = Tool.from_tool( - add_tool, transform_args={"old_y": ArgTransform(drop=True)} + add_tool, transform_args={"old_y": ArgTransform(hide=True)} ) assert sorted(new_tool.parameters["properties"]) == ["old_x"] result = await new_tool.run(arguments={"old_x": 1}) @@ -102,7 +102,7 @@ async def test_tool_drop_arg_with_arg_transform(add_tool): async def test_dropped_args_error_if_provided(add_tool): new_tool = Tool.from_tool( - add_tool, transform_args={"old_y": ArgTransform(drop=True)} + add_tool, transform_args={"old_y": ArgTransform(hide=True)} ) with pytest.raises( TypeError, match="Got unexpected keyword argument\\(s\\): old_y" @@ -110,6 +110,93 @@ async def test_dropped_args_error_if_provided(add_tool): await new_tool.run(arguments={"old_x": 1, "old_y": 2}) +async def test_hidden_arg_with_constant_default(add_tool): + """Test that hidden argument with default value passes constant to parent.""" + new_tool = Tool.from_tool( + add_tool, transform_args={"old_y": ArgTransform(hide=True, default=20)} + ) + # Only old_x should be exposed + assert sorted(new_tool.parameters["properties"]) == ["old_x"] + # Should pass old_x=5 and old_y=20 to parent + result = await new_tool.run(arguments={"old_x": 5}) + assert result[0].text == "25" # type: ignore + + +async def test_hidden_arg_without_default_uses_parent_default(add_tool): + """Test that hidden argument without default uses parent's default.""" + new_tool = Tool.from_tool( + add_tool, transform_args={"old_y": ArgTransform(hide=True)} + ) + # Only old_x should be exposed + assert sorted(new_tool.parameters["properties"]) == ["old_x"] + # Should pass old_x=3 and let parent use its default old_y=10 + result = await new_tool.run(arguments={"old_x": 3}) + assert result[0].text == "13" # type: ignore + + +async def test_mixed_hidden_args_with_custom_function(add_tool): + """Test custom function with both hidden constant and hidden default parameters.""" + + async def custom_fn(visible_x: int) -> int: + # This custom function should receive the transformed visible parameter + # and the hidden parameters should be automatically handled + result = await forward(visible_x=visible_x) + return result + + new_tool = Tool.from_tool( + add_tool, + transform_fn=custom_fn, + transform_args={ + "old_x": "visible_x", # Rename and expose + "old_y": ArgTransform(hide=True, default=25), # Hidden with constant + }, + ) + + # Only visible_x should be exposed + assert sorted(new_tool.parameters["properties"]) == ["visible_x"] + # Should pass visible_x=7 as old_x=7 and old_y=25 to parent + result = await new_tool.run(arguments={"visible_x": 7}) + assert result[0].text == "32" # type: ignore + + +async def test_hide_required_param_without_default_raises_error(): + """Test that hiding a required parameter without providing default raises error.""" + + @Tool.from_function + def tool_with_required_param(required_param: int, optional_param: int = 10) -> int: + return required_param + optional_param + + # This should raise an error because required_param has no default and we're not providing one + with pytest.raises( + ValueError, + match=r"Hidden parameter 'required_param' has no default value in parent tool", + ): + Tool.from_tool( + tool_with_required_param, + transform_args={"required_param": ArgTransform(hide=True)}, + ) + + +async def test_hide_required_param_with_user_default_works(): + """Test that hiding a required parameter works when user provides a default.""" + + @Tool.from_function + def tool_with_required_param(required_param: int, optional_param: int = 10) -> int: + return required_param + optional_param + + # This should work because we're providing a default for the hidden required param + new_tool = Tool.from_tool( + tool_with_required_param, + transform_args={"required_param": ArgTransform(hide=True, default=5)}, + ) + + # Only optional_param should be exposed + assert sorted(new_tool.parameters["properties"]) == ["optional_param"] + # Should pass required_param=5 and optional_param=20 to parent + result = await new_tool.run(arguments={"optional_param": 20}) + assert result[0].text == "25" # type: ignore + + async def test_forward_with_argument_mapping(add_tool): """Test that forward() applies argument mapping correctly."""