From c1f50e49befad282bdfd540130e82a42cf5256ff Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:44:38 -0500 Subject: [PATCH] Add enabled field to ToolTransformConfig (#2991) --- .../transforms/tool-transformation.mdx | 1 + src/fastmcp/tools/tool_transform.py | 38 +++++++- tests/server/test_tool_transformation.py | 89 +++++++++++++++++++ tests/tools/tool_transform/test_metadata.py | 72 +++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/docs/servers/transforms/tool-transformation.mdx b/docs/servers/transforms/tool-transformation.mdx index ec619d46f..8eee6cd23 100644 --- a/docs/servers/transforms/tool-transformation.mdx +++ b/docs/servers/transforms/tool-transformation.mdx @@ -93,6 +93,7 @@ Both mechanisms support the same modifications. | `tags` | Set of tags for categorization | | `annotations` | MCP ToolAnnotations | | `meta` | Custom metadata dictionary | +| `enabled` | Whether the tool is visible to clients (default `True`) | **Argument-level options** (via `ArgTransform` or `ArgTransformConfig`): diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index fbe37a8bb..17f1a256c 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -892,6 +892,29 @@ class TransformedTool(Tool): ) +def _set_visibility_metadata(tool: Tool, *, enabled: bool) -> None: + """Set visibility state in tool metadata. + + This uses the same metadata format as the Visibility transform, + so tools marked here will be filtered by the standard visibility system. + + Args: + tool: Tool to mark. + enabled: Whether the tool should be visible to clients. + """ + # Import here to avoid circular imports + from fastmcp.server.transforms.visibility import _FASTMCP_KEY, _INTERNAL_KEY + + if tool.meta is None: + tool.meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": enabled}}} + else: + old_fastmcp = tool.meta.get(_FASTMCP_KEY, {}) + old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) + new_internal = {**old_internal, "visibility": enabled} + new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} + tool.meta = {**tool.meta, _FASTMCP_KEY: new_fastmcp} + + class ToolTransformConfig(FastMCPBaseModel): """Provides a way to transform a tool.""" @@ -915,6 +938,10 @@ class ToolTransformConfig(FastMCPBaseModel): default=None, description="The new meta information for the tool.", ) + enabled: bool = Field( + default=True, + description="Whether the tool is enabled. If False, the tool will be hidden from clients.", + ) arguments: dict[str, ArgTransformConfig] = Field( default_factory=dict, @@ -925,15 +952,22 @@ class ToolTransformConfig(FastMCPBaseModel): """Create a TransformedTool from a provided tool and this transformation configuration.""" tool_changes: dict[str, Any] = self.model_dump( - exclude_unset=True, exclude={"arguments"} + exclude_unset=True, exclude={"arguments", "enabled"} ) - return TransformedTool.from_tool( + transformed = TransformedTool.from_tool( tool=tool, **tool_changes, transform_args={k: v.to_arg_transform() for k, v in self.arguments.items()}, ) + # Set visibility metadata if enabled was explicitly provided. + # This allows enabled=True to override an earlier disable (later transforms win). + if "enabled" in self.model_fields_set: + _set_visibility_metadata(transformed, enabled=self.enabled) + + return transformed + def apply_transformations_to_tools( tools: dict[str, Tool], diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py index 96654ba9e..4f9833bdf 100644 --- a/tests/server/test_tool_transformation.py +++ b/tests/server/test_tool_transformation.py @@ -118,3 +118,92 @@ async def test_server_level_transforms_apply_to_mounted_servers(): assert "renamed_sub_tool" in tool_names assert "sub_tool" not in tool_names + + +async def test_tool_transform_config_enabled_false_hides_tool(): + """Test that ToolTransformConfig with enabled=False hides the tool from list_tools.""" + mcp = FastMCP("Test Server") + + @mcp.tool() + def visible_tool() -> str: + return "visible" + + @mcp.tool() + def hidden_tool() -> str: + return "hidden" + + # Disable one tool via transformation + mcp.add_transform( + ToolTransform({"hidden_tool": ToolTransformConfig(enabled=False)}) + ) + + tools = await mcp.list_tools() + tool_names = [t.name for t in tools] + + assert "visible_tool" in tool_names + assert "hidden_tool" not in tool_names + + +async def test_tool_transform_config_enabled_false_with_rename(): + """Test that enabled=False works together with other transformations like rename.""" + mcp = FastMCP("Test Server") + + @mcp.tool() + def my_tool() -> str: + return "result" + + # Rename AND disable + mcp.add_transform( + ToolTransform( + {"my_tool": ToolTransformConfig(name="renamed_and_disabled", enabled=False)} + ) + ) + + tools = await mcp.list_tools() + tool_names = [t.name for t in tools] + + # Tool should be hidden regardless of rename + assert "my_tool" not in tool_names + assert "renamed_and_disabled" not in tool_names + + +async def test_tool_transform_config_enabled_true_keeps_tool_visible(): + """Test that ToolTransformConfig with enabled=True (explicit) keeps the tool visible.""" + mcp = FastMCP("Test Server") + + @mcp.tool() + def my_tool() -> str: + return "result" + + # Explicitly set enabled=True (should be same as default) + mcp.add_transform(ToolTransform({"my_tool": ToolTransformConfig(enabled=True)})) + + tools = await mcp.list_tools() + tool_names = [t.name for t in tools] + + assert "my_tool" in tool_names + + +async def test_tool_transform_config_enabled_true_overrides_earlier_disable(): + """Test that ToolTransformConfig with enabled=True can re-enable a previously disabled tool.""" + mcp = FastMCP("Test Server") + + @mcp.tool() + def my_tool() -> str: + return "result" + + # Disable the tool first + mcp.disable(names={"my_tool"}) + + # Verify tool is initially hidden + tools = await mcp.list_tools() + assert "my_tool" not in [t.name for t in tools] + + # Re-enable via transformation (later transforms win) + mcp.add_transform(ToolTransform({"my_tool": ToolTransformConfig(enabled=True)})) + + tools = await mcp.list_tools() + tool_names = [t.name for t in tools] + + # Tool should now be visible + assert "my_tool" in tool_names diff --git a/tests/tools/tool_transform/test_metadata.py b/tests/tools/tool_transform/test_metadata.py index bf611020e..36384afec 100644 --- a/tests/tools/tool_transform/test_metadata.py +++ b/tests/tools/tool_transform/test_metadata.py @@ -170,3 +170,75 @@ def test_tool_transform_config_removes_meta(sample_tool): config = ToolTransformConfig(name="config_tool", meta=None) transformed = config.apply(sample_tool) assert transformed.meta is None + + +# Enabled field tests +def test_tool_transform_config_enabled_defaults_to_true(sample_tool): + """Test that enabled defaults to True and no visibility metadata is set.""" + config = ToolTransformConfig(name="enabled_tool") + transformed = config.apply(sample_tool) + + # No visibility metadata should be set when enabled=True (default) + meta = transformed.meta or {} + internal = meta.get("fastmcp", {}).get("_internal", {}) + assert "visibility" not in internal + + +def test_tool_transform_config_enabled_false_sets_visibility_metadata(sample_tool): + """Test that enabled=False sets visibility metadata to hide the tool.""" + from fastmcp.server.transforms.visibility import is_enabled + + config = ToolTransformConfig(name="disabled_tool", enabled=False) + transformed = config.apply(sample_tool) + + # The is_enabled helper should return False + assert is_enabled(transformed) is False + + # Check the raw metadata structure + assert transformed.meta is not None + assert transformed.meta["fastmcp"]["_internal"]["visibility"] is False + + +def test_tool_transform_config_enabled_true_explicit_sets_visibility(sample_tool): + """Test that enabled=True explicitly sets visibility metadata to allow overriding earlier disables.""" + from fastmcp.server.transforms.visibility import is_enabled + + config = ToolTransformConfig(name="explicit_enabled", enabled=True) + transformed = config.apply(sample_tool) + + # Visibility metadata should be set to True when enabled=True is explicit + # This allows later transforms to override earlier disables + assert is_enabled(transformed) is True + assert transformed.meta is not None + assert transformed.meta["fastmcp"]["_internal"]["visibility"] is True + + +def test_tool_transform_config_enabled_false_preserves_existing_meta(sample_tool): + """Test that enabled=False preserves existing meta while adding visibility.""" + from fastmcp.server.transforms.visibility import is_enabled + + sample_tool.meta = {"custom_key": "custom_value"} + config = ToolTransformConfig(enabled=False) + transformed = config.apply(sample_tool) + + # Original meta should be preserved + assert transformed.meta is not None + assert transformed.meta["custom_key"] == "custom_value" + # Visibility should be set + assert is_enabled(transformed) is False + + +def test_tool_transform_config_enabled_false_merges_with_config_meta(sample_tool): + """Test that enabled=False works with explicit meta override.""" + from fastmcp.server.transforms.visibility import is_enabled + + sample_tool.meta = {"original": True} + config = ToolTransformConfig(meta={"overridden": True}, enabled=False) + transformed = config.apply(sample_tool) + + # Config meta should override original + assert transformed.meta is not None + assert "original" not in transformed.meta + assert transformed.meta["overridden"] is True + # But visibility should still be set + assert is_enabled(transformed) is False