diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index dadc0e4f7..7e245b4ec 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -59,6 +59,7 @@ from fastmcp.settings import Settings from fastmcp.tools import ToolManager from fastmcp.tools.tool import FunctionTool, Tool from fastmcp.utilities.cache import TimedCache +from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_config import MCPConfig @@ -130,6 +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] | None = None, + exclude_tags: set[str] | None = None, # --- # --- # --- The following arguments are DEPRECATED --- @@ -191,6 +194,9 @@ class FastMCP(Generic[LifespanResultT]): tool = Tool.from_function(tool, serializer=self._tool_serializer) self.add_tool(tool) + self.include_tags = include_tags + self.exclude_tags = exclude_tags + # Set up MCP protocol handlers self._setup_handlers() self.dependencies = dependencies or fastmcp.settings.server_dependencies @@ -295,12 +301,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.""" @@ -450,9 +456,13 @@ class FastMCP(Generic[LifespanResultT]): """ tools = await self.get_tools() - return [ - tool.to_mcp_tool(name=key) for key, tool in tools.items() if tool.enabled - ] + + mcp_tools: list[MCPTool] = [] + for key, tool in tools.items(): + if self._should_enable_component(tool): + mcp_tools.append(tool.to_mcp_tool(name=key)) + + return mcp_tools async def _mcp_list_resources(self) -> list[MCPResource]: """ @@ -461,11 +471,11 @@ class FastMCP(Generic[LifespanResultT]): """ resources = await self.get_resources() - return [ - resource.to_mcp_resource(uri=key) - for key, resource in resources.items() - if resource.enabled - ] + mcp_resources: list[MCPResource] = [] + for key, resource in resources.items(): + if self._should_enable_component(resource): + mcp_resources.append(resource.to_mcp_resource(uri=key)) + return mcp_resources async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: """ @@ -474,11 +484,11 @@ class FastMCP(Generic[LifespanResultT]): """ templates = await self.get_resource_templates() - return [ - template.to_mcp_template(uriTemplate=key) - for key, template in templates.items() - if template.enabled - ] + mcp_templates: list[MCPResourceTemplate] = [] + for key, template in templates.items(): + if self._should_enable_component(template): + mcp_templates.append(template.to_mcp_template(uriTemplate=key)) + return mcp_templates async def _mcp_list_prompts(self) -> list[MCPPrompt]: """ @@ -487,11 +497,11 @@ class FastMCP(Generic[LifespanResultT]): """ prompts = await self.get_prompts() - return [ - prompt.to_mcp_prompt(name=key) - for key, prompt in prompts.items() - if prompt.enabled - ] + mcp_prompts: list[MCPPrompt] = [] + for key, prompt in prompts.items(): + if self._should_enable_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] @@ -539,7 +549,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) @@ -576,7 +586,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 [ @@ -630,7 +640,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) @@ -1654,6 +1664,41 @@ class FastMCP(Generic[LifespanResultT]): return cls.as_proxy(client, **settings) + def _should_enable_component( + self, + component: FastMCPComponent, + ) -> bool: + """ + Given a component, determine if it should be enabled. Returns True if it should be enabled; False if it should not. + + Rules: + • 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 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 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 not component.enabled: + return False + + if self.include_tags is None and self.exclude_tags is None: + return True + + if self.exclude_tags is not None: + if any(etag in component.tags for etag in self.exclude_tags): + return False + + if self.include_tags is not None: + if any(itag in component.tags for itag in self.include_tags): + return True + else: + return False + + return True + class MountedServer: def __init__( diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 25c9c7e57..8d6780257 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -233,3 +233,33 @@ class Settings(BaseSettings): ), ), ] = None + + include_tags: Annotated[ + set[str] | None, + Field( + default=None, + description=inspect.cleandoc( + """ + If provided, only components that match these tags will be + 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] | None, + Field( + default=None, + description=inspect.cleandoc( + """ + If provided, components that match these tags will be excluded + from the server. A component is considered to match if ANY of + its tags match ANY of the tags in the set. + """ + ), + ), + ] = None + + +settings = Settings() 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]: 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..dd4719a6e 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1235,3 +1235,106 @@ 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_enable_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_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_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={}) + mcp = FastMCP( + tools=[tool], exclude_tags={"not_present", "tag2", "also_not_present"} + ) + result = mcp._should_enable_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_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_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={}) + mcp = FastMCP( + tools=[tool], include_tags={"not_present", "tag2", "also_not_present"} + ) + 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_enable_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_enable_component(tool) + assert result is False + + 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_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_enable_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_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_enable_component(tool2) + assert result is True diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 09f98d9e5..859918f59 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -115,6 +115,76 @@ 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_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_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() @@ -865,6 +935,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() @@ -1196,6 +1333,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() @@ -1631,3 +1838,73 @@ class TestPromptContext: message = result.messages[0] assert message.role == "user" assert message.content.text == "Hello, World! 1" # 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]