diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 91367360c..d05d942e1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -560,14 +560,31 @@ 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 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 filtering + if self._should_enable_component(tool): + mcp_tools.append(tool) return mcp_tools @@ -611,14 +628,31 @@ 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 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 filtering + if self._should_enable_component(resource): + mcp_resources.append(resource) return mcp_resources @@ -665,14 +699,31 @@ 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 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 filtering + if self._should_enable_component(template): + mcp_templates.append(template) return mcp_templates @@ -717,14 +768,31 @@ 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 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 filtering + if self._should_enable_component(prompt): + mcp_prompts.append(prompt) return mcp_prompts diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index f3303a8f4..30f446938 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -104,21 +104,21 @@ 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 + 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 key, tool in tools_dict.items(): + for tool in filtered_tools: # 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 +132,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 filtered_prompts: 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 +150,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 filtered_resources: 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 +170,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 filtered_templates: 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..23004fb9f 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -412,6 +412,377 @@ 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" + + 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."""