From ad107d258b439c61fd5dc9ad6e4e3d2e249c5749 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 16:38:33 -0700 Subject: [PATCH 01/11] fix: fastmcp inspect should respect tags --- src/fastmcp/utilities/inspect.py | 50 +++++--- tests/utilities/test_inspect.py | 213 +++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 18 deletions(-) diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index f3303a8f4..7b24b61ae 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -104,21 +104,35 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: Returns: FastMCPInfo dataclass containing the extracted information """ - # Get all the components using FastMCP2's direct methods - tools_dict = await mcp.get_tools() - prompts_dict = await mcp.get_prompts() - resources_dict = await mcp.get_resources() - templates_dict = await mcp.get_resource_templates() + # Get all components and apply server-level filtering + all_tools = await mcp._tool_manager.list_tools() + all_prompts = await mcp._prompt_manager.list_prompts() + all_resources = await mcp._resource_manager.list_resources() + all_templates = await mcp._resource_manager.list_resource_templates() + + # Filter components based on server's include_tags/exclude_tags + tools_list = [tool for tool in all_tools if mcp._should_enable_component(tool)] + prompts_list = [ + prompt for prompt in all_prompts if mcp._should_enable_component(prompt) + ] + resources_list = [ + resource for resource in all_resources if mcp._should_enable_component(resource) + ] + templates_list = [ + template + for template in all_templates + if mcp._should_enable_component(template) + ] # Extract detailed tool information tool_infos = [] - for key, tool in tools_dict.items(): + for tool in tools_list: # Convert to MCP tool to get input schema - mcp_tool = tool.to_mcp_tool(name=key) + mcp_tool = tool.to_mcp_tool(name=tool.key) tool_infos.append( ToolInfo( - key=key, - name=tool.name or key, + key=tool.key, + name=tool.name or tool.key, description=tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, output_schema=tool.output_schema, @@ -132,11 +146,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed prompt information prompt_infos = [] - for key, prompt in prompts_dict.items(): + for prompt in prompts_list: prompt_infos.append( PromptInfo( - key=key, - name=prompt.name or key, + key=prompt.key, + name=prompt.name or prompt.key, description=prompt.description, arguments=[arg.model_dump() for arg in prompt.arguments] if prompt.arguments @@ -150,11 +164,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed resource information resource_infos = [] - for key, resource in resources_dict.items(): + for resource in resources_list: resource_infos.append( ResourceInfo( - key=key, - uri=key, # For v2, key is the URI + key=resource.key, + uri=resource.key, # For v2, key is the URI name=resource.name, description=resource.description, mime_type=resource.mime_type, @@ -170,11 +184,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed template information template_infos = [] - for key, template in templates_dict.items(): + for template in templates_list: template_infos.append( TemplateInfo( - key=key, - uri_template=key, # For v2, key is the URI template + key=template.key, + uri_template=template.key, # For v2, key is the URI template name=template.name, description=template.description, mime_type=template.mime_type, diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index 420335c82..0c0155889 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -412,6 +412,219 @@ class TestFastMCP1xCompatibility: assert len(info2x.templates) == 0 +class TestInspectWithTagFiltering: + """Tests for inspect functionality with include_tags and exclude_tags.""" + + async def test_inspect_with_include_tags_filters_tools(self): + """Test that inspect respects include_tags for tools.""" + mcp = FastMCP("TaggedServer", include_tags={"api"}) + + @mcp.tool(tags={"api"}) + def api_tool() -> str: + """API tool.""" + return "api" + + @mcp.tool(tags={"internal"}) + def internal_tool() -> str: + """Internal tool.""" + return "internal" + + @mcp.tool(tags={"api", "internal"}) + def mixed_tool() -> str: + """Mixed tool.""" + return "mixed" + + info = await inspect_fastmcp(mcp) + + # Only tools with 'api' tag should be included + assert len(info.tools) == 2 + tool_names = [tool.name for tool in info.tools] + assert "api_tool" in tool_names + assert "mixed_tool" in tool_names + assert "internal_tool" not in tool_names + + async def test_inspect_with_exclude_tags_filters_tools(self): + """Test that inspect respects exclude_tags for tools.""" + mcp = FastMCP("TaggedServer", exclude_tags={"internal"}) + + @mcp.tool(tags={"api"}) + def api_tool() -> str: + """API tool.""" + return "api" + + @mcp.tool(tags={"internal"}) + def internal_tool() -> str: + """Internal tool.""" + return "internal" + + @mcp.tool(tags={"api", "internal"}) + def mixed_tool() -> str: + """Mixed tool.""" + return "mixed" + + info = await inspect_fastmcp(mcp) + + # Only tools without 'internal' tag should be included + assert len(info.tools) == 1 + tool_names = [tool.name for tool in info.tools] + assert "api_tool" in tool_names + assert "internal_tool" not in tool_names + assert "mixed_tool" not in tool_names + + async def test_inspect_with_include_tags_filters_prompts(self): + """Test that inspect respects include_tags for prompts.""" + mcp = FastMCP("TaggedServer", include_tags={"user-facing"}) + + @mcp.prompt(tags={"user-facing"}) + def user_prompt(text: str) -> list: + """User prompt.""" + return [{"role": "user", "content": text}] + + @mcp.prompt(tags={"admin"}) + def admin_prompt(text: str) -> list: + """Admin prompt.""" + return [{"role": "user", "content": text}] + + info = await inspect_fastmcp(mcp) + + # Only prompts with 'user-facing' tag should be included + assert len(info.prompts) == 1 + prompt_names = [prompt.name for prompt in info.prompts] + assert "user_prompt" in prompt_names + assert "admin_prompt" not in prompt_names + + async def test_inspect_with_include_tags_filters_resources(self): + """Test that inspect respects include_tags for resources.""" + mcp = FastMCP("TaggedServer", include_tags={"public"}) + + @mcp.resource("resource://public", tags={"public"}) + def public_resource() -> str: + """Public resource.""" + return "public data" + + @mcp.resource("resource://private", tags={"private"}) + def private_resource() -> str: + """Private resource.""" + return "private data" + + info = await inspect_fastmcp(mcp) + + # Only resources with 'public' tag should be included + assert len(info.resources) == 1 + resource_uris = [res.uri for res in info.resources] + assert "resource://public" in resource_uris + assert "resource://private" not in resource_uris + + async def test_inspect_with_include_tags_filters_templates(self): + """Test that inspect respects include_tags for resource templates.""" + mcp = FastMCP("TaggedServer", include_tags={"public"}) + + @mcp.resource("resource://public/{id}", tags={"public"}) + def public_template(id: str) -> str: + """Public template.""" + return f"public {id}" + + @mcp.resource("resource://private/{id}", tags={"private"}) + def private_template(id: str) -> str: + """Private template.""" + return f"private {id}" + + info = await inspect_fastmcp(mcp) + + # Only templates with 'public' tag should be included + assert len(info.templates) == 1 + template_uris = [tmpl.uri_template for tmpl in info.templates] + assert "resource://public/{id}" in template_uris + assert "resource://private/{id}" not in template_uris + + async def test_inspect_with_multiple_component_types_filtered(self): + """Test that inspect filters all component types consistently.""" + mcp = FastMCP("TaggedServer", include_tags={"api"}) + + @mcp.tool(tags={"api"}) + def api_tool() -> str: + return "api" + + @mcp.tool(tags={"internal"}) + def internal_tool() -> str: + return "internal" + + @mcp.prompt(tags={"api"}) + def api_prompt() -> list: + return [{"role": "user", "content": "api"}] + + @mcp.prompt(tags={"internal"}) + def internal_prompt() -> list: + return [{"role": "user", "content": "internal"}] + + @mcp.resource("resource://api", tags={"api"}) + def api_resource() -> str: + return "api" + + @mcp.resource("resource://internal", tags={"internal"}) + def internal_resource() -> str: + return "internal" + + info = await inspect_fastmcp(mcp) + + # All component types should be filtered + assert len(info.tools) == 1 + assert len(info.prompts) == 1 + assert len(info.resources) == 1 + assert info.tools[0].name == "api_tool" + assert info.prompts[0].name == "api_prompt" + assert info.resources[0].uri == "resource://api" + + async def test_format_mcp_respects_filtering(self): + """Test that format_mcp_info also respects tag filtering.""" + mcp = FastMCP("TaggedServer", include_tags={"api"}) + + @mcp.tool(tags={"api"}) + def api_tool() -> str: + """API tool.""" + return "api" + + @mcp.tool(tags={"internal"}) + def internal_tool() -> str: + """Internal tool.""" + return "internal" + + json_bytes = await format_mcp_info(mcp) + + import json + + data = json.loads(json_bytes) + + # Only tools with 'api' tag should be in MCP format + assert len(data["tools"]) == 1 + assert data["tools"][0]["name"] == "api_tool" + + async def test_format_fastmcp_respects_filtering(self): + """Test that format_fastmcp_info also respects tag filtering.""" + mcp = FastMCP("TaggedServer", include_tags={"api"}) + + @mcp.tool(tags={"api"}) + def api_tool() -> str: + """API tool.""" + return "api" + + @mcp.tool(tags={"internal"}) + def internal_tool() -> str: + """Internal tool.""" + return "internal" + + info = await inspect_fastmcp(mcp) + json_bytes = await format_fastmcp_info(info) + + import json + + data = json.loads(json_bytes) + + # Only tools with 'api' tag should be in FastMCP format + assert len(data["tools"]) == 1 + assert data["tools"][0]["name"] == "api_tool" + + class TestFormatFunctions: """Tests for the formatting functions.""" From 697ea615ee8aa7735a95ef39754de466f6c2a6a8 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 16:43:44 -0700 Subject: [PATCH 02/11] back to original methods --- src/fastmcp/utilities/inspect.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index 7b24b61ae..69354f0b3 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -105,22 +105,28 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: FastMCPInfo dataclass containing the extracted information """ # Get all components and apply server-level filtering - all_tools = await mcp._tool_manager.list_tools() - all_prompts = await mcp._prompt_manager.list_prompts() - all_resources = await mcp._resource_manager.list_resources() - all_templates = await mcp._resource_manager.list_resource_templates() + all_tools = await mcp.get_tools() + all_prompts = await mcp.get_prompts() + all_resources = await mcp.get_resources() + all_templates = await mcp.get_resource_templates() # Filter components based on server's include_tags/exclude_tags - tools_list = [tool for tool in all_tools if mcp._should_enable_component(tool)] + tools_list = [ + tool for tool in all_tools.values() if mcp._should_enable_component(tool) + ] prompts_list = [ - prompt for prompt in all_prompts if mcp._should_enable_component(prompt) + prompt + for prompt in all_prompts.values() + if mcp._should_enable_component(prompt) ] resources_list = [ - resource for resource in all_resources if mcp._should_enable_component(resource) + resource + for resource in all_resources.values() + if mcp._should_enable_component(resource) ] templates_list = [ template - for template in all_templates + for template in all_templates.values() if mcp._should_enable_component(template) ] From 43e23feb4380be894bf8630eec0292ff1ed4078a Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 20:21:28 -0700 Subject: [PATCH 03/11] try with _list_*_middleware() --- .pre-commit-config.yaml | 1 + src/fastmcp/utilities/inspect.py | 36 +++++++------------------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 548696b55..1600b2a04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,7 @@ repos: hooks: - id: prettier types_or: [yaml, json5] + language_version: "22.17.0" - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index 69354f0b3..30f446938 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -105,34 +105,14 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: FastMCPInfo dataclass containing the extracted information """ # Get all components and apply server-level filtering - all_tools = await mcp.get_tools() - all_prompts = await mcp.get_prompts() - all_resources = await mcp.get_resources() - all_templates = await mcp.get_resource_templates() - - # Filter components based on server's include_tags/exclude_tags - tools_list = [ - tool for tool in all_tools.values() if mcp._should_enable_component(tool) - ] - prompts_list = [ - prompt - for prompt in all_prompts.values() - if mcp._should_enable_component(prompt) - ] - resources_list = [ - resource - for resource in all_resources.values() - if mcp._should_enable_component(resource) - ] - templates_list = [ - template - for template in all_templates.values() - if mcp._should_enable_component(template) - ] + filtered_tools = await mcp._list_tools_middleware() + filtered_prompts = await mcp._list_prompts_middleware() + filtered_resources = await mcp._list_resources_middleware() + filtered_templates = await mcp._list_resource_templates_middleware() # Extract detailed tool information tool_infos = [] - for tool in tools_list: + for tool in filtered_tools: # Convert to MCP tool to get input schema mcp_tool = tool.to_mcp_tool(name=tool.key) tool_infos.append( @@ -152,7 +132,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed prompt information prompt_infos = [] - for prompt in prompts_list: + for prompt in filtered_prompts: prompt_infos.append( PromptInfo( key=prompt.key, @@ -170,7 +150,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed resource information resource_infos = [] - for resource in resources_list: + for resource in filtered_resources: resource_infos.append( ResourceInfo( key=resource.key, @@ -190,7 +170,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed template information template_infos = [] - for template in templates_list: + for template in filtered_templates: template_infos.append( TemplateInfo( key=template.key, From 9ec624cb8b8bca72a7d6a45a1a4abe41d0d88a47 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 20:21:47 -0700 Subject: [PATCH 04/11] revert --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1600b2a04..548696b55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,6 @@ repos: hooks: - id: prettier types_or: [yaml, json5] - language_version: "22.17.0" - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. From cd2808667e7c5fcb8ffde257998ae4917c5f3fe4 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 21:05:32 -0700 Subject: [PATCH 05/11] 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.""" From 238dbf2ddb53d5b5c6dc243906728ae07b48340f Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 21:20:29 -0700 Subject: [PATCH 06/11] should still filter components for proxy server --- src/fastmcp/server/server.py | 60 +++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index ecfc81c54..fac87043b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -574,14 +574,17 @@ class FastMCP(Generic[LifespanResultT]): mcp_tools: list[Tool] = [] for tool in tools: - # 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: + # Only skip filtering if: + # 1. This server has mounted servers, AND + # 2. The tool is from a mounted server (not in local tools) + # For proxy servers with no mounted servers, all tools should be filtered + if self._mounted_servers and tool.key not in local_tool_keys: # Tool from mounted server - already filtered, include as-is mcp_tools.append(tool) + else: + # Local tool or proxy tool - apply this server's tag filtering + if self._should_enable_component(tool): + mcp_tools.append(tool) return mcp_tools @@ -639,14 +642,17 @@ class FastMCP(Generic[LifespanResultT]): mcp_resources: list[Resource] = [] for resource in resources: - # 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: + # Only skip filtering if: + # 1. This server has mounted servers, AND + # 2. The resource is from a mounted server (not in local resources) + # For proxy servers with no mounted servers, all resources should be filtered + if self._mounted_servers and resource.key not in local_resource_keys: # Resource from mounted server - already filtered, include as-is mcp_resources.append(resource) + else: + # Local resource or proxy resource - apply this server's tag filtering + if self._should_enable_component(resource): + mcp_resources.append(resource) return mcp_resources @@ -707,14 +713,17 @@ class FastMCP(Generic[LifespanResultT]): mcp_templates: list[ResourceTemplate] = [] for template in templates: - # 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: + # Only skip filtering if: + # 1. This server has mounted servers, AND + # 2. The template is from a mounted server (not in local templates) + # For proxy servers with no mounted servers, all templates should be filtered + if self._mounted_servers and template.key not in local_template_keys: # Template from mounted server - already filtered, include as-is mcp_templates.append(template) + else: + # Local template or proxy template - apply this server's tag filtering + if self._should_enable_component(template): + mcp_templates.append(template) return mcp_templates @@ -773,14 +782,17 @@ class FastMCP(Generic[LifespanResultT]): mcp_prompts: list[Prompt] = [] for prompt in prompts: - # 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: + # Only skip filtering if: + # 1. This server has mounted servers, AND + # 2. The prompt is from a mounted server (not in local prompts) + # For proxy servers with no mounted servers, all prompts should be filtered + if self._mounted_servers and prompt.key not in local_prompt_keys: # Prompt from mounted server - already filtered, include as-is mcp_prompts.append(prompt) + else: + # Local prompt or proxy prompt - apply this server's tag filtering + if self._should_enable_component(prompt): + mcp_prompts.append(prompt) return mcp_prompts From 44cd0f11cc310c12ab0dc0305dac7bfd8fcc880e Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 9 Oct 2025 21:25:28 -0700 Subject: [PATCH 07/11] ruff --- tests/utilities/test_inspect.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index 77289cd94..23004fb9f 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -672,7 +672,11 @@ class TestInspectWithTagFiltering: 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_")] + 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 @@ -721,12 +725,16 @@ class TestInspectWithTagFiltering: 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/")] + 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/")] + 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 From 9a71909f335c5c72452c7365542b877a72041299 Mon Sep 17 00:00:00 2001 From: Eddie Date: Thu, 9 Oct 2025 21:32:44 -0700 Subject: [PATCH 08/11] Apply suggestion from @parkedwards --- src/fastmcp/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index fac87043b..688f413de 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -582,7 +582,7 @@ class FastMCP(Generic[LifespanResultT]): # Tool from mounted server - already filtered, include as-is mcp_tools.append(tool) else: - # Local tool or proxy tool - apply this server's tag filtering + # Local tool or proxy tool - apply this server's filtering if self._should_enable_component(tool): mcp_tools.append(tool) From bf46b58e4a6e1aeba7eb095611b62e563bc3e40f Mon Sep 17 00:00:00 2001 From: Eddie Date: Thu, 9 Oct 2025 21:33:18 -0700 Subject: [PATCH 09/11] Apply suggestion from @parkedwards --- src/fastmcp/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 688f413de..c2768aae5 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -650,7 +650,7 @@ class FastMCP(Generic[LifespanResultT]): # Resource from mounted server - already filtered, include as-is mcp_resources.append(resource) else: - # Local resource or proxy resource - apply this server's tag filtering + # Local resource or proxy resource - apply this server's filtering if self._should_enable_component(resource): mcp_resources.append(resource) From 2083d3f20ad9448aad9f141ada979b962aa68506 Mon Sep 17 00:00:00 2001 From: Eddie Date: Thu, 9 Oct 2025 21:33:33 -0700 Subject: [PATCH 10/11] Apply suggestion from @parkedwards --- src/fastmcp/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c2768aae5..7934f6c79 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -721,7 +721,7 @@ class FastMCP(Generic[LifespanResultT]): # Template from mounted server - already filtered, include as-is mcp_templates.append(template) else: - # Local template or proxy template - apply this server's tag filtering + # Local template or proxy template - apply this server's filtering if self._should_enable_component(template): mcp_templates.append(template) From 83cc0e6e33d9e037bfbd6e2b733ff2810b3c8f1b Mon Sep 17 00:00:00 2001 From: Eddie Date: Thu, 9 Oct 2025 21:33:54 -0700 Subject: [PATCH 11/11] Apply suggestion from @parkedwards --- src/fastmcp/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 7934f6c79..d05d942e1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -790,7 +790,7 @@ class FastMCP(Generic[LifespanResultT]): # Prompt from mounted server - already filtered, include as-is mcp_prompts.append(prompt) else: - # Local prompt or proxy prompt - apply this server's tag filtering + # Local prompt or proxy prompt - apply this server's filtering if self._should_enable_component(prompt): mcp_prompts.append(prompt)