mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 22:14:18 +02:00
Merge pull request #748 from jlowin/include-exclude
Add support for tag-based component filtering
This commit is contained in:
commit
d818696ffd
6 changed files with 521 additions and 27 deletions
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue