From 956488635eee3e9b113624a67782d1cde7f26a4c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 7 Jun 2025 18:38:29 -0400 Subject: [PATCH 1/5] Add support for tag-based include/exclude --- src/fastmcp/prompts/prompt_manager.py | 6 +- src/fastmcp/server/server.py | 108 ++++++-- src/fastmcp/settings.py | 44 ++++ src/fastmcp/tools/tool.py | 4 +- tests/resources/test_resource_template.py | 41 +++ tests/server/test_server.py | 228 +++++++++++++++++ tests/server/test_server_interactions.py | 291 ++++++++++++++++++++++ 7 files changed, 703 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index fea5ebcd4..4431eadab 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -40,9 +40,11 @@ class PromptManager: self.duplicate_behavior = duplicate_behavior - def get_prompt(self, key: str) -> Prompt | None: + def get_prompt(self, key: str) -> Prompt: """Get prompt by key.""" - return self._prompts.get(key) + if key in self._prompts: + return self._prompts[key] + raise NotFoundError(f"Unknown prompt: {key}") def get_prompts(self) -> dict[str, Prompt]: """Get all registered prompts, indexed by registered key.""" diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 9928ad152..bc26411cb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -129,9 +129,24 @@ class FastMCP(Generic[LifespanResultT]): resource_prefix_format: Literal["protocol", "path"] | None = None, mask_error_details: bool | None = None, tools: list[Tool | Callable[..., Any]] | None = None, + include_tags: set[str] + | set[tuple[str, ...]] + | set[str | tuple[str, ...]] + | None = None, + exclude_tags: set[str] + | set[tuple[str, ...]] + | set[str | tuple[str, ...]] + | None = None, **settings: Any, ): - self.settings = fastmcp.settings.ServerSettings(**settings) +<<<<<<< Updated upstream +======= + if cache_expiration_seconds is not None: + settings["cache_expiration_seconds"] = cache_expiration_seconds +>>>>>>> Stashed changes + self.settings = fastmcp.settings.ServerSettings( + include_tags=include_tags, exclude_tags=exclude_tags, **settings + ) # If mask_error_details is provided, override the settings value if mask_error_details is not None: @@ -146,6 +161,7 @@ class FastMCP(Generic[LifespanResultT]): self.resource_prefix_format = resource_prefix_format self.tags: set[str] = tags or set() + self.dependencies = dependencies self._cache = TimedCache( expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0) @@ -239,12 +255,12 @@ class FastMCP(Generic[LifespanResultT]): def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" self._mcp_server.list_tools()(self._mcp_list_tools) - self._mcp_server.call_tool()(self._mcp_call_tool) self._mcp_server.list_resources()(self._mcp_list_resources) - self._mcp_server.read_resource()(self._mcp_read_resource) - self._mcp_server.list_prompts()(self._mcp_list_prompts) - self._mcp_server.get_prompt()(self._mcp_get_prompt) self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates) + self._mcp_server.list_prompts()(self._mcp_list_prompts) + self._mcp_server.call_tool()(self._mcp_call_tool) + self._mcp_server.read_resource()(self._mcp_read_resource) + self._mcp_server.get_prompt()(self._mcp_get_prompt) async def get_tools(self) -> dict[str, Tool]: """Get all registered tools, indexed by registered key.""" @@ -370,7 +386,13 @@ class FastMCP(Generic[LifespanResultT]): """ tools = await self.get_tools() - return [tool.to_mcp_tool(name=key) for key, tool in tools.items()] + + mcp_tools: list[MCPTool] = [] + for key, tool in tools.items(): + if self.should_include_component(tool): + mcp_tools.append(tool.to_mcp_tool(name=key)) + + return mcp_tools async def _mcp_list_resources(self) -> list[MCPResource]: """ @@ -379,9 +401,11 @@ class FastMCP(Generic[LifespanResultT]): """ resources = await self.get_resources() - return [ - resource.to_mcp_resource(uri=key) for key, resource in resources.items() - ] + mcp_resources: list[MCPResource] = [] + for key, resource in resources.items(): + if self.should_include_component(resource): + mcp_resources.append(resource.to_mcp_resource(uri=key)) + return mcp_resources async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: """ @@ -390,10 +414,11 @@ class FastMCP(Generic[LifespanResultT]): """ templates = await self.get_resource_templates() - return [ - template.to_mcp_template(uriTemplate=key) - for key, template in templates.items() - ] + mcp_templates: list[MCPResourceTemplate] = [] + for key, template in templates.items(): + if self.should_include_component(template): + mcp_templates.append(template.to_mcp_template(uriTemplate=key)) + return mcp_templates async def _mcp_list_prompts(self) -> list[MCPPrompt]: """ @@ -402,7 +427,11 @@ class FastMCP(Generic[LifespanResultT]): """ prompts = await self.get_prompts() - return [prompt.to_mcp_prompt(name=key) for key, prompt in prompts.items()] + mcp_prompts: list[MCPPrompt] = [] + for key, prompt in prompts.items(): + if self.should_include_component(prompt): + mcp_prompts.append(prompt.to_mcp_prompt(name=key)) + return mcp_prompts async def _mcp_call_tool( self, key: str, arguments: dict[str, Any] @@ -422,6 +451,9 @@ class FastMCP(Generic[LifespanResultT]): with fastmcp.server.context.Context(fastmcp=self): # Get tool, checking first from our tools, then from the mounted servers if self._tool_manager.has_tool(key): + tool = self._tool_manager.get_tool(key) + if not self.should_include_component(tool): + raise NotFoundError(f"Unknown tool: {key}") return await self._tool_manager.call_tool(key, arguments) # Check mounted servers to see if they have the tool @@ -440,6 +472,8 @@ class FastMCP(Generic[LifespanResultT]): with fastmcp.server.context.Context(fastmcp=self): if self._resource_manager.has_resource(uri): resource = await self._resource_manager.get_resource(uri) + if not self.should_include_component(resource): + raise NotFoundError(f"Unknown resource: {uri}") content = await self._resource_manager.read_resource(uri) return [ ReadResourceContents( @@ -473,6 +507,9 @@ class FastMCP(Generic[LifespanResultT]): with fastmcp.server.context.Context(fastmcp=self): # Get prompt, checking first from our prompts, then from the mounted servers if self._prompt_manager.has_prompt(name): + prompt = self._prompt_manager.get_prompt(name) + if not self.should_include_component(prompt): + raise NotFoundError(f"Unknown prompt: {name}") return await self._prompt_manager.render_prompt(name, arguments) # Check mounted servers to see if they have the prompt @@ -1506,6 +1543,49 @@ class FastMCP(Generic[LifespanResultT]): return cls.as_proxy(client, **settings) + def should_include_component( + self, + component: Tool | Resource | ResourceTemplate | Prompt, + ) -> bool: + """ + Given a set of tags, determine if the tags match the include and exclude tags. Returns True if it should be included; False if it should not. + + Rules: + • If both include_tags and exclude_tags are None, return True. + • If exclude_tags is provided, check each exclude tag: + - If the exclude tag is a tuple, all tags in the tuple must be present in the input tags to exclude. + - If the exclude tag is a string, it must be present in the input tags to exclude. + • If include_tags is provided, check each include tag: + - If the include tag is a tuple, all tags in the tuple must be present in the input tags to include. + - If the include tag is a string, it must be present in the input tags to include. + • If include_tags is provided and none of the include tags match, return False. + • If include_tags is not provided, return True. + """ + if self.settings.include_tags is None and self.settings.exclude_tags is None: + return True + + if self.settings.exclude_tags is not None: + for etag in self.settings.exclude_tags: + if isinstance(etag, tuple): + if all(et in component.tags for et in etag): + return False + else: + if etag in component.tags: + return False + + if self.settings.include_tags is not None: + for itag in self.settings.include_tags: + if isinstance(itag, tuple): + if all(it in component.tags for it in itag): + return True + else: + if itag in component.tags: + return True + + return False + else: + return True + class MountedServer: def __init__( diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 96939d11c..04c830b27 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -198,5 +198,49 @@ class ServerSettings(BaseSettings): ), ] = None + include_tags: Annotated[ + set[str] | set[tuple[str, ...]] | set[str | tuple[str, ...]] | None, + Field( + default=None, + description=inspect.cleandoc( + """ + If provided, only components that match these tags will be + exposed to clients. This can be a set of tags or tuples of tags. + A component is considered to match if ANY of its tags match ANY + of the tags in the set, or if any combination of its tags match + ALL of the tags in any tuple in the set. + + For example, if include_tags is set to {"tag1", ("tag2", + "tag3")}, then a component with tags {"tag1", "tag4"} or + {"tag2", "tag3", "tag4"} will be included, but a component with + tags {"tag2", "tag4"} will not be included. + """ + ), + ), + ] = None + exclude_tags: Annotated[ + set[str] | set[tuple[str, ...]] | set[str | tuple[str, ...]] | None, + Field( + default=None, + description=inspect.cleandoc( + """ + If provided, components that match these tags will be excluded + from the server. This can be a set of tags or tuples of tags. + This is applied after include_tags, so if a component matches + both include_tags and exclude_tags, it will be excluded. + + A component is considered to match if ANY of its tags match ANY + of the tags in the set, or if any combination of its tags match + ALL of the tags in any tuple in the set. + + For example, if exclude_tags is set to {"tag1", ("tag2", + "tag3")}, then a component with tags {"tag1", "tag4"} or + {"tag2", "tag3", "tag4"} will be excluded, but a component with + tags {"tag2", "tag4"} will not be excluded. + """ + ), + ), + ] = None + settings = Settings() diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index aaa7ff35a..0febb7f86 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -2,7 +2,6 @@ from __future__ import annotations import inspect import json -from abc import ABC, abstractmethod from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any @@ -33,7 +32,7 @@ def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str, indent=2).decode() -class Tool(FastMCPBaseModel, ABC): +class Tool(FastMCPBaseModel): """Internal tool registration info.""" name: str = Field(description="Name of the tool") @@ -91,7 +90,6 @@ class Tool(FastMCPBaseModel, ABC): assert isinstance(other, type(self)) return self.model_dump() == other.model_dump() - @abstractmethod async def run( self, arguments: dict[str, Any] ) -> list[TextContent | ImageContent | EmbeddedResource]: diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 27a4f794f..efb9dea65 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -558,6 +558,47 @@ class TestMatchUriTemplate: result = match_uri_template(uri=uri, uri_template=uri_template) assert result == expected_params + @pytest.mark.parametrize( + "uri, expected_params", + [ + ("resource://test_foo", {"x": "foo"}), + ("resource://test_bar", {"x": "bar"}), + ("resource://test_hello", {"x": "hello"}), + ("resource://test_with_underscores", {"x": "with_underscores"}), + ("resource://test_", None), # Empty parameter not matched + ("resource://test", None), # Missing parameter delimiter + ("resource://other_foo", None), # Wrong prefix + ("other://test_foo", None), # Wrong scheme + ], + ) + def test_match_uri_template_embedded_param( + self, uri: str, expected_params: dict[str, str] | None + ): + """Test matching URIs where parameter is embedded within a word segment.""" + uri_template = "resource://test_{x}" + result = match_uri_template(uri=uri, uri_template=uri_template) + assert result == expected_params + + @pytest.mark.parametrize( + "uri, expected_params", + [ + ("resource://prefix_foo_suffix", {"x": "foo"}), + ("resource://prefix_bar_suffix", {"x": "bar"}), + ("resource://prefix_hello_world_suffix", {"x": "hello_world"}), + ("resource://prefix__suffix", None), # Empty parameter not matched + ("resource://prefix_suffix", None), # Missing parameter delimiter + ("resource://other_foo_suffix", None), # Wrong prefix + ("resource://prefix_foo_other", None), # Wrong suffix + ], + ) + def test_match_uri_template_embedded_param_with_prefix_and_suffix( + self, uri: str, expected_params: dict[str, str] | None + ): + """Test matching URIs where parameter has both prefix and suffix.""" + uri_template = "resource://prefix_{x}_suffix" + result = match_uri_template(uri=uri, uri_template=uri_template) + assert result == expected_params + class TestContextHandling: """Test context handling in resource templates.""" diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 6292640a1..34f3cd1a7 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1235,3 +1235,231 @@ class TestResourcePrefixMounting: "resource://imported/param-value/template" ) assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined] + + +class TestShouldIncludeComponent: + def test_no_filters_returns_true(self): + """Test that when no include or exclude filters are provided, always returns True.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp = FastMCP(tools=[tool]) + result = mcp.should_include_component(tool) + assert result is True + + def test_exclude_string_tag_present_returns_false(self): + """Test that when an exclude string tag is present in tags, returns False.""" + tool = Tool( + name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={} + ) + mcp = FastMCP(tools=[tool], exclude_tags={"exclude_me"}) + result = mcp.should_include_component(tool) + assert result is False + + def test_exclude_string_tag_absent_returns_true(self): + """Test that when an exclude string tag is not present in tags, returns True.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp = FastMCP(tools=[tool], exclude_tags={"exclude_me"}) + result = mcp.should_include_component(tool) + assert result is True + + def test_exclude_tuple_all_present_returns_false(self): + """Test that when all tags in exclude tuple are present, returns False.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) + mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")}) + result = mcp.should_include_component(tool) + assert result is False + + def test_exclude_tuple_partial_present_returns_true(self): + """Test that when only some tags in exclude tuple are present, returns True.""" + tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={}) + mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")}) + result = mcp.should_include_component(tool) + assert result is True + + def test_multiple_exclude_tags_any_match_returns_false(self): + """Test that when any exclude tag matches, returns False.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) + mcp = FastMCP( + tools=[tool], exclude_tags={"not_present", "tag2", "also_not_present"} + ) + result = mcp.should_include_component(tool) + assert result is False + + def test_include_string_tag_present_returns_true(self): + """Test that when an include string tag is present in tags, returns True.""" + tool = Tool( + name="test_tool", tags={"tag1", "include_me", "tag2"}, parameters={} + ) + mcp = FastMCP(tools=[tool], include_tags={"include_me"}) + result = mcp.should_include_component(tool) + assert result is True + + def test_include_string_tag_absent_returns_false(self): + """Test that when an include string tag is not present in tags, returns False.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp = FastMCP(tools=[tool], include_tags={"include_me"}) + result = mcp.should_include_component(tool) + assert result is False + + def test_include_tuple_all_present_returns_true(self): + """Test that when all tags in include tuple are present, returns True.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) + mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")}) + result = mcp.should_include_component(tool) + assert result is True + + def test_include_tuple_partial_present_returns_false(self): + """Test that when only some tags in include tuple are present, returns False.""" + tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={}) + mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")}) + result = mcp.should_include_component(tool) + assert result is False + + def test_multiple_include_tags_any_match_returns_true(self): + """Test that when any include tag matches, returns True.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) + mcp = FastMCP( + tools=[tool], include_tags={"not_present", "tag2", "also_not_present"} + ) + result = mcp.should_include_component(tool) + assert result is True + + def test_multiple_include_tags_none_match_returns_false(self): + """Test that when no include tags match, returns False.""" + tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) + mcp = FastMCP(tools=[tool], include_tags={"not_present", "also_not_present"}) + result = mcp.should_include_component(tool) + assert result is False + + def test_exclude_takes_precedence_over_include(self): + """Test that exclude tags take precedence over include tags.""" + tool = Tool( + name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={} + ) + mcp = FastMCP(tools=[tool], include_tags={"tag1"}, exclude_tags={"exclude_me"}) + result = mcp.should_include_component(tool) + assert result is False + + def test_mixed_string_and_tuple_exclude_tags(self): + """Test exclude tags with both string and tuple formats.""" + # Should be excluded because "tag1" is present + tool1 = Tool( + name="test_tool", tags={"tag1", "tag2", "tag3", "tag4"}, parameters={} + ) + mcp1 = FastMCP(tools=[tool1], exclude_tags={"tag1", ("tag2", "tag3")}) + result = mcp1.should_include_component(tool1) + assert result is False + + # Remove tag1, should still be excluded because both tag2 and tag3 are present + tool2 = Tool(name="test_tool", tags={"tag2", "tag3", "tag4"}, parameters={}) + mcp2 = FastMCP(tools=[tool2], exclude_tags={"tag1", ("tag2", "tag3")}) + result = mcp2.should_include_component(tool2) + assert result is False + + # Remove tag2, should not be excluded + tool3 = Tool( + name="test_tool", tags={"tag1_removed", "tag3", "tag4"}, parameters={} + ) + mcp3 = FastMCP(tools=[tool3], exclude_tags={("tag2", "tag3")}) + result = mcp3.should_include_component(tool3) + assert result is True + + def test_mixed_string_and_tuple_include_tags(self): + """Test include tags with both string and tuple formats.""" + # Should be included because both tag1 and tag2 are present (tuple match) + tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp1 = FastMCP(tools=[tool1], include_tags={"not_present", ("tag1", "tag2")}) + result = mcp1.should_include_component(tool1) + assert result is True + + # Should be included because tag1 is present (string match) + tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp2 = FastMCP( + tools=[tool2], include_tags={"tag1", ("not_present1", "not_present2")} + ) + result = mcp2.should_include_component(tool2) + assert result is True + + # Should not be included because no conditions are met + tool3 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp3 = FastMCP( + tools=[tool3], + include_tags={"not_present", ("not_present1", "not_present2")}, + ) + result = mcp3.should_include_component(tool3) + assert result is False + + def test_complex_scenario_with_both_filters(self): + """Test complex scenario with both include and exclude filters.""" + # Should be excluded despite matching include conditions + tool1 = Tool( + name="test_tool", tags={"api", "read", "admin", "sensitive"}, parameters={} + ) + mcp1 = FastMCP( + tools=[tool1], + include_tags={"api", ("read", "admin")}, + exclude_tags={"sensitive"}, + ) + result = mcp1.should_include_component(tool1) + assert result is False + + # Remove sensitive tag, should now be included + tool2 = Tool(name="test_tool", tags={"api", "read", "admin"}, parameters={}) + mcp2 = FastMCP( + tools=[tool2], + include_tags={"api", ("read", "admin")}, + exclude_tags={"sensitive"}, + ) + result = mcp2.should_include_component(tool2) + assert result is True + + def test_empty_include_exclude_sets(self): + """Test behavior with empty include/exclude sets.""" + # Empty include set means nothing matches + tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp1 = FastMCP(tools=[tool1], include_tags=set()) + result = mcp1.should_include_component(tool1) + assert result is False + + # Empty exclude set means nothing excluded + tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp2 = FastMCP(tools=[tool2], exclude_tags=set()) + result = mcp2.should_include_component(tool2) + assert result is True + + def test_empty_tags_with_filters(self): + """Test behavior when input tags are empty.""" + # With include filters, empty tags should not match + tool1 = Tool(name="test_tool", tags=set(), parameters={}) + mcp1 = FastMCP(tools=[tool1], include_tags={"required_tag"}) + result = mcp1.should_include_component(tool1) + assert result is False + + # With exclude filters but no include, empty tags should pass + tool2 = Tool(name="test_tool", tags=set(), parameters={}) + mcp2 = FastMCP(tools=[tool2], exclude_tags={"bad_tag"}) + result = mcp2.should_include_component(tool2) + assert result is True + + # Tuple filters with empty tags + tool3 = Tool(name="test_tool", tags=set(), parameters={}) + mcp3 = FastMCP(tools=[tool3], include_tags={("tag1", "tag2")}) + result = mcp3.should_include_component(tool3) + assert result is False + + tool4 = Tool(name="test_tool", tags=set(), parameters={}) + mcp4 = FastMCP(tools=[tool4], exclude_tags={("tag1", "tag2")}) + result = mcp4.should_include_component(tool4) + assert result is True + + def test_single_element_tuples(self): + """Test behavior with single-element tuples.""" + # Single-element tuple should behave like a string + tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp1 = FastMCP(tools=[tool1], include_tags={("tag1",)}) + result = mcp1.should_include_component(tool1) + assert result is True + + tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) + mcp2 = FastMCP(tools=[tool2], exclude_tags={("tag1",)}) + result = mcp2.should_include_component(tool2) + assert result is False diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 9d6df95bc..b0fb61f1a 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -115,6 +115,90 @@ class TestTools: assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined] +class TestToolTags: + def create_server(self, include_tags=None, exclude_tags=None): + mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + + @mcp.tool(tags={"a", "b"}) + def tool_1() -> int: + return 1 + + @mcp.tool(tags={"b", "c"}) + def tool_2() -> int: + return 2 + + return mcp + + async def test_include_tags_all_tools(self): + mcp = self.create_server(include_tags={"a", "b"}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == {"tool_1", "tool_2"} + + async def test_include_tags_some_tools(self): + mcp = self.create_server(include_tags={"a", "z"}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == {"tool_1"} + + async def test_include_tags_tuple(self): + mcp = self.create_server(include_tags={("a", "b")}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == {"tool_1"} + + async def test_exclude_tags_all_tools(self): + mcp = self.create_server(exclude_tags={"a", "b"}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == set() + + async def test_exclude_tags_some_tools(self): + mcp = self.create_server(exclude_tags={"a", "z"}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == {"tool_2"} + + async def test_exclude_tags_tuple(self): + mcp = self.create_server(exclude_tags={("a", "b")}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == {"tool_2"} + + async def test_exclude_precedence(self): + mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"}) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert {t.name for t in tools} == {"tool_2"} + + async def test_call_included_tool(self): + mcp = self.create_server(include_tags={"a"}) + + async with Client(mcp) as client: + result_1 = await client.call_tool("tool_1", {}) + assert result_1[0].text == "1" # type: ignore[attr-defined] + + with pytest.raises(ToolError, match="Unknown tool"): + await client.call_tool("tool_2", {}) + + async def test_call_excluded_tool(self): + mcp = self.create_server(exclude_tags={"a"}) + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="Unknown tool"): + await client.call_tool("tool_1", {}) + + result_2 = await client.call_tool("tool_2", {}) + assert result_2[0].text == "2" # type: ignore[attr-defined] + + class TestToolReturnTypes: async def test_string(self): mcp = FastMCP() @@ -769,6 +853,73 @@ class TestResource: assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined] +class TestResourceTags: + def create_server(self, include_tags=None, exclude_tags=None): + mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + + @mcp.resource("resource://1", tags={"a", "b"}) + def resource_1() -> str: + return "1" + + @mcp.resource("resource://2", tags={"b", "c"}) + def resource_2() -> str: + return "2" + + return mcp + + async def test_include_tags_all_resources(self): + mcp = self.create_server(include_tags={"a", "b"}) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert {r.name for r in resources} == {"resource_1", "resource_2"} + + async def test_include_tags_some_resources(self): + mcp = self.create_server(include_tags={"a", "z"}) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert {r.name for r in resources} == {"resource_1"} + + async def test_exclude_tags_all_resources(self): + mcp = self.create_server(exclude_tags={"a", "b"}) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert {r.name for r in resources} == set() + + async def test_exclude_tags_some_resources(self): + mcp = self.create_server(exclude_tags={"a", "z"}) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert {r.name for r in resources} == {"resource_2"} + + async def test_exclude_precedence(self): + mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"}) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert {r.name for r in resources} == {"resource_2"} + + async def test_read_included_resource(self): + mcp = self.create_server(include_tags={"a"}) + + async with Client(mcp) as client: + result = await client.read_resource(AnyUrl("resource://1")) + assert result[0].text == "1" # type: ignore[attr-defined] + + with pytest.raises(McpError, match="Unknown resource"): + await client.read_resource(AnyUrl("resource://2")) + + async def test_read_excluded_resource(self): + mcp = self.create_server(exclude_tags={"a"}) + + async with Client(mcp) as client: + with pytest.raises(McpError, match="Unknown resource"): + await client.read_resource(AnyUrl("resource://1")) + + class TestResourceContext: async def test_resource_with_context_annotation_gets_context(self): mcp = FastMCP() @@ -1004,6 +1155,76 @@ class TestResourceTemplates: assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined] +class TestResourceTemplatesTags: + def create_server(self, include_tags=None, exclude_tags=None): + mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + + @mcp.resource("resource://1/{param}", tags={"a", "b"}) + def template_resource_1(param: str) -> str: + return f"Template resource 1: {param}" + + @mcp.resource("resource://2/{param}", tags={"b", "c"}) + def template_resource_2(param: str) -> str: + return f"Template resource 2: {param}" + + return mcp + + async def test_include_tags_all_resources(self): + mcp = self.create_server(include_tags={"a", "b"}) + + async with Client(mcp) as client: + resources = await client.list_resource_templates() + assert {r.name for r in resources} == { + "template_resource_1", + "template_resource_2", + } + + async def test_include_tags_some_resources(self): + mcp = self.create_server(include_tags={"a"}) + + async with Client(mcp) as client: + resources = await client.list_resource_templates() + assert {r.name for r in resources} == {"template_resource_1"} + + async def test_exclude_tags_all_resources(self): + mcp = self.create_server(exclude_tags={"a", "b"}) + + async with Client(mcp) as client: + resources = await client.list_resource_templates() + assert {r.name for r in resources} == set() + + async def test_exclude_tags_some_resources(self): + mcp = self.create_server(exclude_tags={"a"}) + + async with Client(mcp) as client: + resources = await client.list_resource_templates() + assert {r.name for r in resources} == {"template_resource_2"} + + async def test_exclude_takes_precedence_over_include(self): + mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"}) + + async with Client(mcp) as client: + resources = await client.list_resource_templates() + assert {r.name for r in resources} == {"template_resource_2"} + + async def test_read_resource_template_includes_tags(self): + mcp = self.create_server(include_tags={"a"}) + + async with Client(mcp) as client: + result = await client.read_resource("resource://1/x") + assert result[0].text == "Template resource 1: x" # type: ignore[attr-defined] + + with pytest.raises(McpError, match="Unknown resource"): + await client.read_resource("resource://2/x") + + async def test_read_resource_template_excludes_tags(self): + mcp = self.create_server(exclude_tags={"a"}) + + async with Client(mcp) as client: + with pytest.raises(McpError, match="Unknown resource"): + await client.read_resource("resource://1/x") + + class TestResourceTemplateContext: async def test_resource_template_context(self): mcp = FastMCP() @@ -1250,3 +1471,73 @@ class TestPromptContext: message = result.messages[0] assert message.role == "user" assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined] + + +class TestPromptTags: + def create_server(self, include_tags=None, exclude_tags=None): + mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + + @mcp.prompt(tags={"a", "b"}) + def prompt_1() -> str: + return "1" + + @mcp.prompt(tags={"b", "c"}) + def prompt_2() -> str: + return "2" + + return mcp + + async def test_include_tags_all_prompts(self): + mcp = self.create_server(include_tags={"a", "b"}) + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert {p.name for p in prompts} == {"prompt_1", "prompt_2"} + + async def test_include_tags_some_prompts(self): + mcp = self.create_server(include_tags={"a"}) + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert {p.name for p in prompts} == {"prompt_1"} + + async def test_exclude_tags_all_prompts(self): + mcp = self.create_server(exclude_tags={"a", "b"}) + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert {p.name for p in prompts} == set() + + async def test_exclude_tags_some_prompts(self): + mcp = self.create_server(exclude_tags={"a"}) + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert {p.name for p in prompts} == {"prompt_2"} + + async def test_exclude_takes_precedence_over_include(self): + mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"}) + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert {p.name for p in prompts} == {"prompt_2"} + + async def test_read_prompt_includes_tags(self): + mcp = self.create_server(include_tags={"a"}) + + async with Client(mcp) as client: + result = await client.get_prompt("prompt_1") + assert result.messages[0].content.text == "1" # type: ignore[attr-defined] + + with pytest.raises(McpError, match="Unknown prompt"): + await client.get_prompt("prompt_2") + + async def test_read_prompt_excludes_tags(self): + mcp = self.create_server(exclude_tags={"a"}) + + async with Client(mcp) as client: + with pytest.raises(McpError, match="Unknown prompt"): + await client.get_prompt("prompt_1") + + result = await client.get_prompt("prompt_2") + assert result.messages[0].content.text == "2" # type: ignore[attr-defined] From 8ba1478dc5c55ddc38eb22aa2375980a6f026267 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 7 Jun 2025 18:39:55 -0400 Subject: [PATCH 2/5] Fix bad merge --- src/fastmcp/server/server.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bc26411cb..d72c0351a 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -139,11 +139,8 @@ class FastMCP(Generic[LifespanResultT]): | None = None, **settings: Any, ): -<<<<<<< Updated upstream -======= if cache_expiration_seconds is not None: settings["cache_expiration_seconds"] = cache_expiration_seconds ->>>>>>> Stashed changes self.settings = fastmcp.settings.ServerSettings( include_tags=include_tags, exclude_tags=exclude_tags, **settings ) From 7224295f935f363255d76eabbf8d78004e223939 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:32:31 -0400 Subject: [PATCH 3/5] Incorporate `enabled` property --- src/fastmcp/server/server.py | 6 ++-- tests/server/test_server.py | 58 ++++++++++++++++++------------------ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index eeb41d05c..bf2a29d18 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -555,7 +555,7 @@ class FastMCP(Generic[LifespanResultT]): # Get tool, checking first from our tools, then from the mounted servers if self._tool_manager.has_tool(key): tool = self._tool_manager.get_tool(key) - if not tool.enabled: + if not self._should_enable_component(tool): raise DisabledError(f"Tool {key!r} is disabled") return await self._tool_manager.call_tool(key, arguments) @@ -592,7 +592,7 @@ class FastMCP(Generic[LifespanResultT]): """ if self._resource_manager.has_resource(uri): resource = await self._resource_manager.get_resource(uri) - if not resource.enabled: + if not self._should_enable_component(resource): raise DisabledError(f"Resource {str(uri)!r} is disabled") content = await self._resource_manager.read_resource(uri) return [ @@ -646,7 +646,7 @@ class FastMCP(Generic[LifespanResultT]): # Get prompt, checking first from our prompts, then from the mounted servers if self._prompt_manager.has_prompt(name): prompt = self._prompt_manager.get_prompt(name) - if not prompt.enabled: + if not self._should_enable_component(prompt): raise DisabledError(f"Prompt {name!r} is disabled") return await self._prompt_manager.render_prompt(name, arguments) diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 34f3cd1a7..7a0ee3e4e 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1242,7 +1242,7 @@ class TestShouldIncludeComponent: """Test that when no include or exclude filters are provided, always returns True.""" tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp = FastMCP(tools=[tool]) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is True def test_exclude_string_tag_present_returns_false(self): @@ -1251,28 +1251,28 @@ class TestShouldIncludeComponent: name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={} ) mcp = FastMCP(tools=[tool], exclude_tags={"exclude_me"}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_exclude_string_tag_absent_returns_true(self): """Test that when an exclude string tag is not present in tags, returns True.""" tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp = FastMCP(tools=[tool], exclude_tags={"exclude_me"}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is True def test_exclude_tuple_all_present_returns_false(self): """Test that when all tags in exclude tuple are present, returns False.""" tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_exclude_tuple_partial_present_returns_true(self): """Test that when only some tags in exclude tuple are present, returns True.""" tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={}) mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is True def test_multiple_exclude_tags_any_match_returns_false(self): @@ -1281,7 +1281,7 @@ class TestShouldIncludeComponent: mcp = FastMCP( tools=[tool], exclude_tags={"not_present", "tag2", "also_not_present"} ) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_include_string_tag_present_returns_true(self): @@ -1290,28 +1290,28 @@ class TestShouldIncludeComponent: name="test_tool", tags={"tag1", "include_me", "tag2"}, parameters={} ) mcp = FastMCP(tools=[tool], include_tags={"include_me"}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is True def test_include_string_tag_absent_returns_false(self): """Test that when an include string tag is not present in tags, returns False.""" tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp = FastMCP(tools=[tool], include_tags={"include_me"}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_include_tuple_all_present_returns_true(self): """Test that when all tags in include tuple are present, returns True.""" tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is True def test_include_tuple_partial_present_returns_false(self): """Test that when only some tags in include tuple are present, returns False.""" tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={}) mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_multiple_include_tags_any_match_returns_true(self): @@ -1320,14 +1320,14 @@ class TestShouldIncludeComponent: mcp = FastMCP( tools=[tool], include_tags={"not_present", "tag2", "also_not_present"} ) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is True def test_multiple_include_tags_none_match_returns_false(self): """Test that when no include tags match, returns False.""" tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) mcp = FastMCP(tools=[tool], include_tags={"not_present", "also_not_present"}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_exclude_takes_precedence_over_include(self): @@ -1336,7 +1336,7 @@ class TestShouldIncludeComponent: name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={} ) mcp = FastMCP(tools=[tool], include_tags={"tag1"}, exclude_tags={"exclude_me"}) - result = mcp.should_include_component(tool) + result = mcp._should_enable_component(tool) assert result is False def test_mixed_string_and_tuple_exclude_tags(self): @@ -1346,13 +1346,13 @@ class TestShouldIncludeComponent: name="test_tool", tags={"tag1", "tag2", "tag3", "tag4"}, parameters={} ) mcp1 = FastMCP(tools=[tool1], exclude_tags={"tag1", ("tag2", "tag3")}) - result = mcp1.should_include_component(tool1) + result = mcp1._should_enable_component(tool1) assert result is False # Remove tag1, should still be excluded because both tag2 and tag3 are present tool2 = Tool(name="test_tool", tags={"tag2", "tag3", "tag4"}, parameters={}) mcp2 = FastMCP(tools=[tool2], exclude_tags={"tag1", ("tag2", "tag3")}) - result = mcp2.should_include_component(tool2) + result = mcp2._should_enable_component(tool2) assert result is False # Remove tag2, should not be excluded @@ -1360,7 +1360,7 @@ class TestShouldIncludeComponent: name="test_tool", tags={"tag1_removed", "tag3", "tag4"}, parameters={} ) mcp3 = FastMCP(tools=[tool3], exclude_tags={("tag2", "tag3")}) - result = mcp3.should_include_component(tool3) + result = mcp3._should_enable_component(tool3) assert result is True def test_mixed_string_and_tuple_include_tags(self): @@ -1368,7 +1368,7 @@ class TestShouldIncludeComponent: # Should be included because both tag1 and tag2 are present (tuple match) tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp1 = FastMCP(tools=[tool1], include_tags={"not_present", ("tag1", "tag2")}) - result = mcp1.should_include_component(tool1) + result = mcp1._should_enable_component(tool1) assert result is True # Should be included because tag1 is present (string match) @@ -1376,7 +1376,7 @@ class TestShouldIncludeComponent: mcp2 = FastMCP( tools=[tool2], include_tags={"tag1", ("not_present1", "not_present2")} ) - result = mcp2.should_include_component(tool2) + result = mcp2._should_enable_component(tool2) assert result is True # Should not be included because no conditions are met @@ -1385,7 +1385,7 @@ class TestShouldIncludeComponent: tools=[tool3], include_tags={"not_present", ("not_present1", "not_present2")}, ) - result = mcp3.should_include_component(tool3) + result = mcp3._should_enable_component(tool3) assert result is False def test_complex_scenario_with_both_filters(self): @@ -1399,7 +1399,7 @@ class TestShouldIncludeComponent: include_tags={"api", ("read", "admin")}, exclude_tags={"sensitive"}, ) - result = mcp1.should_include_component(tool1) + result = mcp1._should_enable_component(tool1) assert result is False # Remove sensitive tag, should now be included @@ -1409,7 +1409,7 @@ class TestShouldIncludeComponent: include_tags={"api", ("read", "admin")}, exclude_tags={"sensitive"}, ) - result = mcp2.should_include_component(tool2) + result = mcp2._should_enable_component(tool2) assert result is True def test_empty_include_exclude_sets(self): @@ -1417,13 +1417,13 @@ class TestShouldIncludeComponent: # Empty include set means nothing matches tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp1 = FastMCP(tools=[tool1], include_tags=set()) - result = mcp1.should_include_component(tool1) + result = mcp1._should_enable_component(tool1) assert result is False # Empty exclude set means nothing excluded tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp2 = FastMCP(tools=[tool2], exclude_tags=set()) - result = mcp2.should_include_component(tool2) + result = mcp2._should_enable_component(tool2) assert result is True def test_empty_tags_with_filters(self): @@ -1431,24 +1431,24 @@ class TestShouldIncludeComponent: # With include filters, empty tags should not match tool1 = Tool(name="test_tool", tags=set(), parameters={}) mcp1 = FastMCP(tools=[tool1], include_tags={"required_tag"}) - result = mcp1.should_include_component(tool1) + result = mcp1._should_enable_component(tool1) assert result is False # With exclude filters but no include, empty tags should pass tool2 = Tool(name="test_tool", tags=set(), parameters={}) mcp2 = FastMCP(tools=[tool2], exclude_tags={"bad_tag"}) - result = mcp2.should_include_component(tool2) + result = mcp2._should_enable_component(tool2) assert result is True # Tuple filters with empty tags tool3 = Tool(name="test_tool", tags=set(), parameters={}) mcp3 = FastMCP(tools=[tool3], include_tags={("tag1", "tag2")}) - result = mcp3.should_include_component(tool3) + result = mcp3._should_enable_component(tool3) assert result is False tool4 = Tool(name="test_tool", tags=set(), parameters={}) mcp4 = FastMCP(tools=[tool4], exclude_tags={("tag1", "tag2")}) - result = mcp4.should_include_component(tool4) + result = mcp4._should_enable_component(tool4) assert result is True def test_single_element_tuples(self): @@ -1456,10 +1456,10 @@ class TestShouldIncludeComponent: # Single-element tuple should behave like a string tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp1 = FastMCP(tools=[tool1], include_tags={("tag1",)}) - result = mcp1.should_include_component(tool1) + result = mcp1._should_enable_component(tool1) assert result is True tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) mcp2 = FastMCP(tools=[tool2], exclude_tags={("tag1",)}) - result = mcp2.should_include_component(tool2) + result = mcp2._should_enable_component(tool2) assert result is False From ec9fab070cc736f6a011530cc0261198440aaddb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:33:08 -0400 Subject: [PATCH 4/5] Remove ABC --- src/fastmcp/tools/tool.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 6e825dc6d..a66cac6d5 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -2,7 +2,6 @@ from __future__ import annotations import inspect import json -from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -33,7 +32,7 @@ def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str, indent=2).decode() -class Tool(FastMCPComponent, ABC): +class Tool(FastMCPComponent): """Internal tool registration info.""" parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") @@ -76,7 +75,6 @@ class Tool(FastMCPComponent, ABC): enabled=enabled, ) - @abstractmethod async def run( self, arguments: dict[str, Any] ) -> list[TextContent | ImageContent | EmbeddedResource]: From c5fb5f7fd20591cd9265be186ff1c739ec1eea60 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:50:43 -0400 Subject: [PATCH 5/5] Remove tuple tags --- src/fastmcp/server/server.py | 36 ++----- src/fastmcp/settings.py | 29 ++---- tests/server/test_server.py | 125 ----------------------- tests/server/test_server_interactions.py | 14 --- 4 files changed, 15 insertions(+), 189 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bf2a29d18..7e245b4ec 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -131,14 +131,8 @@ class FastMCP(Generic[LifespanResultT]): mask_error_details: bool | None = None, tools: list[Tool | Callable[..., Any]] | None = None, dependencies: list[str] | None = None, - include_tags: set[str] - | set[tuple[str, ...]] - | set[str | tuple[str, ...]] - | None = None, - exclude_tags: set[str] - | set[tuple[str, ...]] - | set[str | tuple[str, ...]] - | None = None, + include_tags: set[str] | None = None, + exclude_tags: set[str] | None = None, # --- # --- # --- The following arguments are DEPRECATED --- @@ -1681,10 +1675,8 @@ class FastMCP(Generic[LifespanResultT]): • If the component's enabled property is False, always return False. • If both include_tags and exclude_tags are None, return True. • If exclude_tags is provided, check each exclude tag: - - If the exclude tag is a tuple, all tags in the tuple must be present in the input tags to exclude. - If the exclude tag is a string, it must be present in the input tags to exclude. • If include_tags is provided, check each include tag: - - If the include tag is a tuple, all tags in the tuple must be present in the input tags to include. - If the include tag is a string, it must be present in the input tags to include. • If include_tags is provided and none of the include tags match, return False. • If include_tags is not provided, return True. @@ -1696,26 +1688,16 @@ class FastMCP(Generic[LifespanResultT]): return True if self.exclude_tags is not None: - for etag in self.exclude_tags: - if isinstance(etag, tuple): - if all(et in component.tags for et in etag): - return False - else: - if etag in component.tags: - return False + if any(etag in component.tags for etag in self.exclude_tags): + return False if self.include_tags is not None: - for itag in self.include_tags: - if isinstance(itag, tuple): - if all(it in component.tags for it in itag): - return True - else: - if itag in component.tags: - return True + if any(itag in component.tags for itag in self.include_tags): + return True + else: + return False - return False - else: - return True + return True class MountedServer: diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 941c5b319..8d6780257 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -235,44 +235,27 @@ class Settings(BaseSettings): ] = None include_tags: Annotated[ - set[str] | set[tuple[str, ...]] | set[str | tuple[str, ...]] | None, + set[str] | None, Field( default=None, description=inspect.cleandoc( """ If provided, only components that match these tags will be - exposed to clients. This can be a set of tags or tuples of tags. - A component is considered to match if ANY of its tags match ANY - of the tags in the set, or if any combination of its tags match - ALL of the tags in any tuple in the set. - - For example, if include_tags is set to {"tag1", ("tag2", - "tag3")}, then a component with tags {"tag1", "tag4"} or - {"tag2", "tag3", "tag4"} will be included, but a component with - tags {"tag2", "tag4"} will not be included. + exposed to clients. A component is considered to match if ANY of + its tags match ANY of the tags in the set. """ ), ), ] = None exclude_tags: Annotated[ - set[str] | set[tuple[str, ...]] | set[str | tuple[str, ...]] | None, + set[str] | None, Field( default=None, description=inspect.cleandoc( """ If provided, components that match these tags will be excluded - from the server. This can be a set of tags or tuples of tags. - This is applied after include_tags, so if a component matches - both include_tags and exclude_tags, it will be excluded. - - A component is considered to match if ANY of its tags match ANY - of the tags in the set, or if any combination of its tags match - ALL of the tags in any tuple in the set. - - For example, if exclude_tags is set to {"tag1", ("tag2", - "tag3")}, then a component with tags {"tag1", "tag4"} or - {"tag2", "tag3", "tag4"} will be excluded, but a component with - tags {"tag2", "tag4"} will not be excluded. + from the server. A component is considered to match if ANY of + its tags match ANY of the tags in the set. """ ), ), diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 7a0ee3e4e..dd4719a6e 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1261,20 +1261,6 @@ class TestShouldIncludeComponent: result = mcp._should_enable_component(tool) assert result is True - def test_exclude_tuple_all_present_returns_false(self): - """Test that when all tags in exclude tuple are present, returns False.""" - tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) - mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")}) - result = mcp._should_enable_component(tool) - assert result is False - - def test_exclude_tuple_partial_present_returns_true(self): - """Test that when only some tags in exclude tuple are present, returns True.""" - tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={}) - mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")}) - result = mcp._should_enable_component(tool) - assert result is True - def test_multiple_exclude_tags_any_match_returns_false(self): """Test that when any exclude tag matches, returns False.""" tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) @@ -1300,20 +1286,6 @@ class TestShouldIncludeComponent: result = mcp._should_enable_component(tool) assert result is False - def test_include_tuple_all_present_returns_true(self): - """Test that when all tags in include tuple are present, returns True.""" - tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) - mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")}) - result = mcp._should_enable_component(tool) - assert result is True - - def test_include_tuple_partial_present_returns_false(self): - """Test that when only some tags in include tuple are present, returns False.""" - tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={}) - mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")}) - result = mcp._should_enable_component(tool) - assert result is False - def test_multiple_include_tags_any_match_returns_true(self): """Test that when any include tag matches, returns True.""" tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={}) @@ -1339,79 +1311,6 @@ class TestShouldIncludeComponent: result = mcp._should_enable_component(tool) assert result is False - def test_mixed_string_and_tuple_exclude_tags(self): - """Test exclude tags with both string and tuple formats.""" - # Should be excluded because "tag1" is present - tool1 = Tool( - name="test_tool", tags={"tag1", "tag2", "tag3", "tag4"}, parameters={} - ) - mcp1 = FastMCP(tools=[tool1], exclude_tags={"tag1", ("tag2", "tag3")}) - result = mcp1._should_enable_component(tool1) - assert result is False - - # Remove tag1, should still be excluded because both tag2 and tag3 are present - tool2 = Tool(name="test_tool", tags={"tag2", "tag3", "tag4"}, parameters={}) - mcp2 = FastMCP(tools=[tool2], exclude_tags={"tag1", ("tag2", "tag3")}) - result = mcp2._should_enable_component(tool2) - assert result is False - - # Remove tag2, should not be excluded - tool3 = Tool( - name="test_tool", tags={"tag1_removed", "tag3", "tag4"}, parameters={} - ) - mcp3 = FastMCP(tools=[tool3], exclude_tags={("tag2", "tag3")}) - result = mcp3._should_enable_component(tool3) - assert result is True - - def test_mixed_string_and_tuple_include_tags(self): - """Test include tags with both string and tuple formats.""" - # Should be included because both tag1 and tag2 are present (tuple match) - tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) - mcp1 = FastMCP(tools=[tool1], include_tags={"not_present", ("tag1", "tag2")}) - result = mcp1._should_enable_component(tool1) - assert result is True - - # Should be included because tag1 is present (string match) - tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) - mcp2 = FastMCP( - tools=[tool2], include_tags={"tag1", ("not_present1", "not_present2")} - ) - result = mcp2._should_enable_component(tool2) - assert result is True - - # Should not be included because no conditions are met - tool3 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) - mcp3 = FastMCP( - tools=[tool3], - include_tags={"not_present", ("not_present1", "not_present2")}, - ) - result = mcp3._should_enable_component(tool3) - assert result is False - - def test_complex_scenario_with_both_filters(self): - """Test complex scenario with both include and exclude filters.""" - # Should be excluded despite matching include conditions - tool1 = Tool( - name="test_tool", tags={"api", "read", "admin", "sensitive"}, parameters={} - ) - mcp1 = FastMCP( - tools=[tool1], - include_tags={"api", ("read", "admin")}, - exclude_tags={"sensitive"}, - ) - result = mcp1._should_enable_component(tool1) - assert result is False - - # Remove sensitive tag, should now be included - tool2 = Tool(name="test_tool", tags={"api", "read", "admin"}, parameters={}) - mcp2 = FastMCP( - tools=[tool2], - include_tags={"api", ("read", "admin")}, - exclude_tags={"sensitive"}, - ) - result = mcp2._should_enable_component(tool2) - assert result is True - def test_empty_include_exclude_sets(self): """Test behavior with empty include/exclude sets.""" # Empty include set means nothing matches @@ -1439,27 +1338,3 @@ class TestShouldIncludeComponent: mcp2 = FastMCP(tools=[tool2], exclude_tags={"bad_tag"}) result = mcp2._should_enable_component(tool2) assert result is True - - # Tuple filters with empty tags - tool3 = Tool(name="test_tool", tags=set(), parameters={}) - mcp3 = FastMCP(tools=[tool3], include_tags={("tag1", "tag2")}) - result = mcp3._should_enable_component(tool3) - assert result is False - - tool4 = Tool(name="test_tool", tags=set(), parameters={}) - mcp4 = FastMCP(tools=[tool4], exclude_tags={("tag1", "tag2")}) - result = mcp4._should_enable_component(tool4) - assert result is True - - def test_single_element_tuples(self): - """Test behavior with single-element tuples.""" - # Single-element tuple should behave like a string - tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) - mcp1 = FastMCP(tools=[tool1], include_tags={("tag1",)}) - result = mcp1._should_enable_component(tool1) - assert result is True - - tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={}) - mcp2 = FastMCP(tools=[tool2], exclude_tags={("tag1",)}) - result = mcp2._should_enable_component(tool2) - assert result is False diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index efd6de6e8..859918f59 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -143,13 +143,6 @@ class TestToolTags: tools = await client.list_tools() assert {t.name for t in tools} == {"tool_1"} - async def test_include_tags_tuple(self): - mcp = self.create_server(include_tags={("a", "b")}) - - async with Client(mcp) as client: - tools = await client.list_tools() - assert {t.name for t in tools} == {"tool_1"} - async def test_exclude_tags_all_tools(self): mcp = self.create_server(exclude_tags={"a", "b"}) @@ -164,13 +157,6 @@ class TestToolTags: tools = await client.list_tools() assert {t.name for t in tools} == {"tool_2"} - async def test_exclude_tags_tuple(self): - mcp = self.create_server(exclude_tags={("a", "b")}) - - async with Client(mcp) as client: - tools = await client.list_tools() - assert {t.name for t in tools} == {"tool_2"} - async def test_exclude_precedence(self): mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})