From cd2808667e7c5fcb8ffde257998ae4917c5f3fe4 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 21:05:32 -0700 Subject: [PATCH] properly handle mounted server filtering --- src/fastmcp/server/server.py | 72 +++++++++++++-- tests/utilities/test_inspect.py | 150 ++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 91367360c..ecfc81c54 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -560,13 +560,27 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[mcp.types.ListToolsRequest], ) -> list[Tool]: """ - List all available tools + List all available tools. + + Note: Mounted servers have already applied their own filtering via + _list_tools_middleware(), so we should NOT re-filter them with the + parent's tags. Only apply tag filtering to local tools. """ + # Get all tools - this includes both local tools and filtered tools from mounted servers tools = await self._tool_manager.list_tools() # type: ignore[reportPrivateUsage] + # Get the set of local tool keys (not from mounted servers) + local_tool_keys = set(self._tool_manager._tools.keys()) + mcp_tools: list[Tool] = [] for tool in tools: - if self._should_enable_component(tool): + # Only apply parent's tag filtering to local tools + # Mounted server tools are already filtered by their own rules + if tool.key in local_tool_keys: + if self._should_enable_component(tool): + mcp_tools.append(tool) + else: + # Tool from mounted server - already filtered, include as-is mcp_tools.append(tool) return mcp_tools @@ -611,13 +625,27 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[dict[str, Any]], ) -> list[Resource]: """ - List all available resources + List all available resources. + + Note: Mounted servers have already applied their own filtering via + _list_resources_middleware(), so we should NOT re-filter them with the + parent's tags. Only apply tag filtering to local resources. """ + # Get all resources - this includes both local resources and filtered resources from mounted servers resources = await self._resource_manager.list_resources() # type: ignore[reportPrivateUsage] + # Get the set of local resource keys (not from mounted servers) + local_resource_keys = set(self._resource_manager._resources.keys()) + mcp_resources: list[Resource] = [] for resource in resources: - if self._should_enable_component(resource): + # Only apply parent's tag filtering to local resources + # Mounted server resources are already filtered by their own rules + if resource.key in local_resource_keys: + if self._should_enable_component(resource): + mcp_resources.append(resource) + else: + # Resource from mounted server - already filtered, include as-is mcp_resources.append(resource) return mcp_resources @@ -665,13 +693,27 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[dict[str, Any]], ) -> list[ResourceTemplate]: """ - List all available resource templates + List all available resource templates. + + Note: Mounted servers have already applied their own filtering via + _list_resource_templates_middleware(), so we should NOT re-filter them with the + parent's tags. Only apply tag filtering to local templates. """ + # Get all templates - this includes both local templates and filtered templates from mounted servers templates = await self._resource_manager.list_resource_templates() # type: ignore[reportPrivateUsage] + # Get the set of local template keys (not from mounted servers) + local_template_keys = set(self._resource_manager._templates.keys()) + mcp_templates: list[ResourceTemplate] = [] for template in templates: - if self._should_enable_component(template): + # Only apply parent's tag filtering to local templates + # Mounted server templates are already filtered by their own rules + if template.key in local_template_keys: + if self._should_enable_component(template): + mcp_templates.append(template) + else: + # Template from mounted server - already filtered, include as-is mcp_templates.append(template) return mcp_templates @@ -717,13 +759,27 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[mcp.types.ListPromptsRequest], ) -> list[Prompt]: """ - List all available prompts + List all available prompts. + + Note: Mounted servers have already applied their own filtering via + _list_prompts_middleware(), so we should NOT re-filter them with the + parent's tags. Only apply tag filtering to local prompts. """ + # Get all prompts - this includes both local prompts and filtered prompts from mounted servers prompts = await self._prompt_manager.list_prompts() # type: ignore[reportPrivateUsage] + # Get the set of local prompt keys (not from mounted servers) + local_prompt_keys = set(self._prompt_manager._prompts.keys()) + mcp_prompts: list[Prompt] = [] for prompt in prompts: - if self._should_enable_component(prompt): + # Only apply parent's tag filtering to local prompts + # Mounted server prompts are already filtered by their own rules + if prompt.key in local_prompt_keys: + if self._should_enable_component(prompt): + mcp_prompts.append(prompt) + else: + # Prompt from mounted server - already filtered, include as-is mcp_prompts.append(prompt) return mcp_prompts diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index 0c0155889..77289cd94 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -624,6 +624,156 @@ class TestInspectWithTagFiltering: assert len(data["tools"]) == 1 assert data["tools"][0]["name"] == "api_tool" + async def test_inspect_mounted_servers_with_tag_filtering(self): + """Test that inspect respects tag filtering in mounted servers.""" + # Create child servers with different tag filtering rules + child1 = FastMCP("child1", include_tags={"admin"}) + + @child1.tool(tags={"admin"}) + def child1_admin_tool() -> str: + """Child1 Admin Tool.""" + return "admin" + + @child1.tool(tags={"public"}) + def child1_public_tool() -> str: + """Child1 Public Tool (should be hidden).""" + return "public" + + child2 = FastMCP("child2", exclude_tags={"internal"}) + + @child2.tool(tags={"public"}) + def child2_public_tool() -> str: + """Child2 Public Tool.""" + return "public" + + @child2.tool(tags={"internal"}) + def child2_internal_tool() -> str: + """Child2 Internal Tool (should be hidden).""" + return "internal" + + # Create parent server with its own filtering + parent = FastMCP("parent", include_tags={"show"}) + + @parent.tool(tags={"show"}) + def parent_show_tool() -> str: + """Parent Show Tool.""" + return "show" + + @parent.tool() + def parent_hide_tool() -> str: + """Parent Hide Tool (should be hidden).""" + return "hide" + + # Mount children + parent.mount(child1, prefix="c1") + parent.mount(child2, prefix="c2") + + # Inspect the parent server + info = await inspect_fastmcp(parent) + + # Verify parent's filtering is applied to local tools only + parent_tool_keys = [t.key for t in info.tools if not t.key.startswith("c1_") and not t.key.startswith("c2_")] + assert "parent_show_tool" in parent_tool_keys + assert "parent_hide_tool" not in parent_tool_keys + + # Verify child1's filtering is preserved (only admin tools) + child1_tool_keys = [t.key for t in info.tools if t.key.startswith("c1_")] + assert "c1_child1_admin_tool" in child1_tool_keys + assert "c1_child1_public_tool" not in child1_tool_keys + + # Verify child2's filtering is preserved (exclude internal tools) + child2_tool_keys = [t.key for t in info.tools if t.key.startswith("c2_")] + assert "c2_child2_public_tool" in child2_tool_keys + assert "c2_child2_internal_tool" not in child2_tool_keys + + async def test_inspect_mounted_servers_with_resources_filtering(self): + """Test that inspect respects tag filtering for resources in mounted servers.""" + # Create child server with resource filtering + child = FastMCP("child", include_tags={"public"}) + + @child.resource("resource://child/public", tags={"public"}) + def child_public_resource() -> str: + """Child Public Resource.""" + return "public" + + @child.resource("resource://child/private", tags={"private"}) + def child_private_resource() -> str: + """Child Private Resource (should be hidden).""" + return "private" + + # Create parent server with its own filtering + parent = FastMCP("parent", include_tags={"show"}) + + @parent.resource("resource://parent/show", tags={"show"}) + def parent_show_resource() -> str: + """Parent Show Resource.""" + return "show" + + @parent.resource("resource://parent/hide") + def parent_hide_resource() -> str: + """Parent Hide Resource (should be hidden).""" + return "hide" + + # Mount child + parent.mount(child, prefix="c") + + # Inspect the parent server + info = await inspect_fastmcp(parent) + + # Verify parent's resources + parent_resource_uris = [r.uri for r in info.resources if not r.uri.startswith("resource://c/")] + assert "resource://parent/show" in parent_resource_uris + assert "resource://parent/hide" not in parent_resource_uris + + # Verify child's filtering is preserved + child_resource_uris = [r.uri for r in info.resources if r.uri.startswith("resource://c/")] + assert "resource://c/child/public" in child_resource_uris + assert "resource://c/child/private" not in child_resource_uris + + async def test_inspect_mounted_servers_with_prompts_filtering(self): + """Test that inspect respects tag filtering for prompts in mounted servers.""" + # Create child server with prompt filtering + child = FastMCP("child", exclude_tags={"internal"}) + + @child.prompt(tags={"user"}) + def child_user_prompt() -> list: + """Child User Prompt.""" + return [{"role": "user", "content": "user"}] + + @child.prompt(tags={"internal"}) + def child_internal_prompt() -> list: + """Child Internal Prompt (should be hidden).""" + return [{"role": "user", "content": "internal"}] + + # Create parent server with its own filtering + parent = FastMCP("parent", include_tags={"api"}) + + @parent.prompt(tags={"api"}) + def parent_api_prompt() -> list: + """Parent API Prompt.""" + return [{"role": "user", "content": "api"}] + + @parent.prompt() + def parent_other_prompt() -> list: + """Parent Other Prompt (should be hidden).""" + return [{"role": "user", "content": "other"}] + + # Mount child + parent.mount(child, prefix="c") + + # Inspect the parent server + info = await inspect_fastmcp(parent) + + # Verify parent's prompts + parent_prompt_keys = [p.key for p in info.prompts if not p.key.startswith("c_")] + assert "parent_api_prompt" in parent_prompt_keys + assert "parent_other_prompt" not in parent_prompt_keys + + # Verify child's filtering is preserved + child_prompt_keys = [p.key for p in info.prompts if p.key.startswith("c_")] + assert "c_child_user_prompt" in child_prompt_keys + assert "c_child_internal_prompt" not in child_prompt_keys + class TestFormatFunctions: """Tests for the formatting functions."""