Merge pull request #748 from jlowin/include-exclude

Add support for tag-based component filtering
This commit is contained in:
Jeremiah Lowin 2025-06-10 20:47:43 -04:00 committed by GitHub
commit d818696ffd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 521 additions and 27 deletions

View file

@ -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__(

View file

@ -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()

View file

@ -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]:

View file

@ -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."""

View file

@ -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

View file

@ -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]