Jeremiah Lowin commited on
Commit
55aaa0d
·
unverified ·
2 Parent(s): e3c1e6923dba76

Merge pull request #748 from jlowin/include-exclude

Browse files
src/fastmcp/server/server.py CHANGED
@@ -59,6 +59,7 @@ from fastmcp.settings import Settings
59
  from fastmcp.tools import ToolManager
60
  from fastmcp.tools.tool import FunctionTool, Tool
61
  from fastmcp.utilities.cache import TimedCache
 
62
  from fastmcp.utilities.logging import get_logger
63
  from fastmcp.utilities.mcp_config import MCPConfig
64
 
@@ -130,6 +131,8 @@ class FastMCP(Generic[LifespanResultT]):
130
  mask_error_details: bool | None = None,
131
  tools: list[Tool | Callable[..., Any]] | None = None,
132
  dependencies: list[str] | None = None,
 
 
133
  # ---
134
  # ---
135
  # --- The following arguments are DEPRECATED ---
@@ -191,6 +194,9 @@ class FastMCP(Generic[LifespanResultT]):
191
  tool = Tool.from_function(tool, serializer=self._tool_serializer)
192
  self.add_tool(tool)
193
 
 
 
 
194
  # Set up MCP protocol handlers
195
  self._setup_handlers()
196
  self.dependencies = dependencies or fastmcp.settings.server_dependencies
@@ -295,12 +301,12 @@ class FastMCP(Generic[LifespanResultT]):
295
  def _setup_handlers(self) -> None:
296
  """Set up core MCP protocol handlers."""
297
  self._mcp_server.list_tools()(self._mcp_list_tools)
298
- self._mcp_server.call_tool()(self._mcp_call_tool)
299
  self._mcp_server.list_resources()(self._mcp_list_resources)
300
- self._mcp_server.read_resource()(self._mcp_read_resource)
301
  self._mcp_server.list_prompts()(self._mcp_list_prompts)
 
 
302
  self._mcp_server.get_prompt()(self._mcp_get_prompt)
303
- self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
304
 
305
  async def get_tools(self) -> dict[str, Tool]:
306
  """Get all registered tools, indexed by registered key."""
@@ -450,9 +456,13 @@ class FastMCP(Generic[LifespanResultT]):
450
 
451
  """
452
  tools = await self.get_tools()
453
- return [
454
- tool.to_mcp_tool(name=key) for key, tool in tools.items() if tool.enabled
455
- ]
 
 
 
 
456
 
457
  async def _mcp_list_resources(self) -> list[MCPResource]:
458
  """
@@ -461,11 +471,11 @@ class FastMCP(Generic[LifespanResultT]):
461
 
462
  """
463
  resources = await self.get_resources()
464
- return [
465
- resource.to_mcp_resource(uri=key)
466
- for key, resource in resources.items()
467
- if resource.enabled
468
- ]
469
 
470
  async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
471
  """
@@ -474,11 +484,11 @@ class FastMCP(Generic[LifespanResultT]):
474
 
475
  """
476
  templates = await self.get_resource_templates()
477
- return [
478
- template.to_mcp_template(uriTemplate=key)
479
- for key, template in templates.items()
480
- if template.enabled
481
- ]
482
 
483
  async def _mcp_list_prompts(self) -> list[MCPPrompt]:
484
  """
@@ -487,11 +497,11 @@ class FastMCP(Generic[LifespanResultT]):
487
 
488
  """
489
  prompts = await self.get_prompts()
490
- return [
491
- prompt.to_mcp_prompt(name=key)
492
- for key, prompt in prompts.items()
493
- if prompt.enabled
494
- ]
495
 
496
  async def _mcp_call_tool(
497
  self, key: str, arguments: dict[str, Any]
@@ -539,7 +549,7 @@ class FastMCP(Generic[LifespanResultT]):
539
  # Get tool, checking first from our tools, then from the mounted servers
540
  if self._tool_manager.has_tool(key):
541
  tool = self._tool_manager.get_tool(key)
542
- if not tool.enabled:
543
  raise DisabledError(f"Tool {key!r} is disabled")
544
  return await self._tool_manager.call_tool(key, arguments)
545
 
@@ -576,7 +586,7 @@ class FastMCP(Generic[LifespanResultT]):
576
  """
577
  if self._resource_manager.has_resource(uri):
578
  resource = await self._resource_manager.get_resource(uri)
579
- if not resource.enabled:
580
  raise DisabledError(f"Resource {str(uri)!r} is disabled")
581
  content = await self._resource_manager.read_resource(uri)
582
  return [
@@ -630,7 +640,7 @@ class FastMCP(Generic[LifespanResultT]):
630
  # Get prompt, checking first from our prompts, then from the mounted servers
631
  if self._prompt_manager.has_prompt(name):
632
  prompt = self._prompt_manager.get_prompt(name)
633
- if not prompt.enabled:
634
  raise DisabledError(f"Prompt {name!r} is disabled")
635
  return await self._prompt_manager.render_prompt(name, arguments)
636
 
@@ -1654,6 +1664,41 @@ class FastMCP(Generic[LifespanResultT]):
1654
 
1655
  return cls.as_proxy(client, **settings)
1656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1657
 
1658
  class MountedServer:
1659
  def __init__(
 
59
  from fastmcp.tools import ToolManager
60
  from fastmcp.tools.tool import FunctionTool, Tool
61
  from fastmcp.utilities.cache import TimedCache
62
+ from fastmcp.utilities.components import FastMCPComponent
63
  from fastmcp.utilities.logging import get_logger
64
  from fastmcp.utilities.mcp_config import MCPConfig
65
 
 
131
  mask_error_details: bool | None = None,
132
  tools: list[Tool | Callable[..., Any]] | None = None,
133
  dependencies: list[str] | None = None,
134
+ include_tags: set[str] | None = None,
135
+ exclude_tags: set[str] | None = None,
136
  # ---
137
  # ---
138
  # --- The following arguments are DEPRECATED ---
 
194
  tool = Tool.from_function(tool, serializer=self._tool_serializer)
195
  self.add_tool(tool)
196
 
197
+ self.include_tags = include_tags
198
+ self.exclude_tags = exclude_tags
199
+
200
  # Set up MCP protocol handlers
201
  self._setup_handlers()
202
  self.dependencies = dependencies or fastmcp.settings.server_dependencies
 
301
  def _setup_handlers(self) -> None:
302
  """Set up core MCP protocol handlers."""
303
  self._mcp_server.list_tools()(self._mcp_list_tools)
 
304
  self._mcp_server.list_resources()(self._mcp_list_resources)
305
+ self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
306
  self._mcp_server.list_prompts()(self._mcp_list_prompts)
307
+ self._mcp_server.call_tool()(self._mcp_call_tool)
308
+ self._mcp_server.read_resource()(self._mcp_read_resource)
309
  self._mcp_server.get_prompt()(self._mcp_get_prompt)
 
310
 
311
  async def get_tools(self) -> dict[str, Tool]:
312
  """Get all registered tools, indexed by registered key."""
 
456
 
457
  """
458
  tools = await self.get_tools()
459
+
460
+ mcp_tools: list[MCPTool] = []
461
+ for key, tool in tools.items():
462
+ if self._should_enable_component(tool):
463
+ mcp_tools.append(tool.to_mcp_tool(name=key))
464
+
465
+ return mcp_tools
466
 
467
  async def _mcp_list_resources(self) -> list[MCPResource]:
468
  """
 
471
 
472
  """
473
  resources = await self.get_resources()
474
+ mcp_resources: list[MCPResource] = []
475
+ for key, resource in resources.items():
476
+ if self._should_enable_component(resource):
477
+ mcp_resources.append(resource.to_mcp_resource(uri=key))
478
+ return mcp_resources
479
 
480
  async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
481
  """
 
484
 
485
  """
486
  templates = await self.get_resource_templates()
487
+ mcp_templates: list[MCPResourceTemplate] = []
488
+ for key, template in templates.items():
489
+ if self._should_enable_component(template):
490
+ mcp_templates.append(template.to_mcp_template(uriTemplate=key))
491
+ return mcp_templates
492
 
493
  async def _mcp_list_prompts(self) -> list[MCPPrompt]:
494
  """
 
497
 
498
  """
499
  prompts = await self.get_prompts()
500
+ mcp_prompts: list[MCPPrompt] = []
501
+ for key, prompt in prompts.items():
502
+ if self._should_enable_component(prompt):
503
+ mcp_prompts.append(prompt.to_mcp_prompt(name=key))
504
+ return mcp_prompts
505
 
506
  async def _mcp_call_tool(
507
  self, key: str, arguments: dict[str, Any]
 
549
  # Get tool, checking first from our tools, then from the mounted servers
550
  if self._tool_manager.has_tool(key):
551
  tool = self._tool_manager.get_tool(key)
552
+ if not self._should_enable_component(tool):
553
  raise DisabledError(f"Tool {key!r} is disabled")
554
  return await self._tool_manager.call_tool(key, arguments)
555
 
 
586
  """
587
  if self._resource_manager.has_resource(uri):
588
  resource = await self._resource_manager.get_resource(uri)
589
+ if not self._should_enable_component(resource):
590
  raise DisabledError(f"Resource {str(uri)!r} is disabled")
591
  content = await self._resource_manager.read_resource(uri)
592
  return [
 
640
  # Get prompt, checking first from our prompts, then from the mounted servers
641
  if self._prompt_manager.has_prompt(name):
642
  prompt = self._prompt_manager.get_prompt(name)
643
+ if not self._should_enable_component(prompt):
644
  raise DisabledError(f"Prompt {name!r} is disabled")
645
  return await self._prompt_manager.render_prompt(name, arguments)
646
 
 
1664
 
1665
  return cls.as_proxy(client, **settings)
1666
 
1667
+ def _should_enable_component(
1668
+ self,
1669
+ component: FastMCPComponent,
1670
+ ) -> bool:
1671
+ """
1672
+ Given a component, determine if it should be enabled. Returns True if it should be enabled; False if it should not.
1673
+
1674
+ Rules:
1675
+ • If the component's enabled property is False, always return False.
1676
+ • If both include_tags and exclude_tags are None, return True.
1677
+ • If exclude_tags is provided, check each exclude tag:
1678
+ - If the exclude tag is a string, it must be present in the input tags to exclude.
1679
+ • If include_tags is provided, check each include tag:
1680
+ - If the include tag is a string, it must be present in the input tags to include.
1681
+ • If include_tags is provided and none of the include tags match, return False.
1682
+ • If include_tags is not provided, return True.
1683
+ """
1684
+ if not component.enabled:
1685
+ return False
1686
+
1687
+ if self.include_tags is None and self.exclude_tags is None:
1688
+ return True
1689
+
1690
+ if self.exclude_tags is not None:
1691
+ if any(etag in component.tags for etag in self.exclude_tags):
1692
+ return False
1693
+
1694
+ if self.include_tags is not None:
1695
+ if any(itag in component.tags for itag in self.include_tags):
1696
+ return True
1697
+ else:
1698
+ return False
1699
+
1700
+ return True
1701
+
1702
 
1703
  class MountedServer:
1704
  def __init__(
src/fastmcp/settings.py CHANGED
@@ -233,3 +233,33 @@ class Settings(BaseSettings):
233
  ),
234
  ),
235
  ] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  ),
234
  ),
235
  ] = None
236
+
237
+ include_tags: Annotated[
238
+ set[str] | None,
239
+ Field(
240
+ default=None,
241
+ description=inspect.cleandoc(
242
+ """
243
+ If provided, only components that match these tags will be
244
+ exposed to clients. A component is considered to match if ANY of
245
+ its tags match ANY of the tags in the set.
246
+ """
247
+ ),
248
+ ),
249
+ ] = None
250
+ exclude_tags: Annotated[
251
+ set[str] | None,
252
+ Field(
253
+ default=None,
254
+ description=inspect.cleandoc(
255
+ """
256
+ If provided, components that match these tags will be excluded
257
+ from the server. A component is considered to match if ANY of
258
+ its tags match ANY of the tags in the set.
259
+ """
260
+ ),
261
+ ),
262
+ ] = None
263
+
264
+
265
+ settings = Settings()
src/fastmcp/tools/tool.py CHANGED
@@ -2,7 +2,6 @@ from __future__ import annotations
2
 
3
  import inspect
4
  import json
5
- from abc import ABC, abstractmethod
6
  from collections.abc import Callable
7
  from dataclasses import dataclass
8
  from typing import TYPE_CHECKING, Any
@@ -33,7 +32,7 @@ def default_serializer(data: Any) -> str:
33
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
34
 
35
 
36
- class Tool(FastMCPComponent, ABC):
37
  """Internal tool registration info."""
38
 
39
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
@@ -76,7 +75,6 @@ class Tool(FastMCPComponent, ABC):
76
  enabled=enabled,
77
  )
78
 
79
- @abstractmethod
80
  async def run(
81
  self, arguments: dict[str, Any]
82
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
 
2
 
3
  import inspect
4
  import json
 
5
  from collections.abc import Callable
6
  from dataclasses import dataclass
7
  from typing import TYPE_CHECKING, Any
 
32
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
33
 
34
 
35
+ class Tool(FastMCPComponent):
36
  """Internal tool registration info."""
37
 
38
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
 
75
  enabled=enabled,
76
  )
77
 
 
78
  async def run(
79
  self, arguments: dict[str, Any]
80
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
tests/resources/test_resource_template.py CHANGED
@@ -558,6 +558,47 @@ class TestMatchUriTemplate:
558
  result = match_uri_template(uri=uri, uri_template=uri_template)
559
  assert result == expected_params
560
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
 
562
  class TestContextHandling:
563
  """Test context handling in resource templates."""
 
558
  result = match_uri_template(uri=uri, uri_template=uri_template)
559
  assert result == expected_params
560
 
561
+ @pytest.mark.parametrize(
562
+ "uri, expected_params",
563
+ [
564
+ ("resource://test_foo", {"x": "foo"}),
565
+ ("resource://test_bar", {"x": "bar"}),
566
+ ("resource://test_hello", {"x": "hello"}),
567
+ ("resource://test_with_underscores", {"x": "with_underscores"}),
568
+ ("resource://test_", None), # Empty parameter not matched
569
+ ("resource://test", None), # Missing parameter delimiter
570
+ ("resource://other_foo", None), # Wrong prefix
571
+ ("other://test_foo", None), # Wrong scheme
572
+ ],
573
+ )
574
+ def test_match_uri_template_embedded_param(
575
+ self, uri: str, expected_params: dict[str, str] | None
576
+ ):
577
+ """Test matching URIs where parameter is embedded within a word segment."""
578
+ uri_template = "resource://test_{x}"
579
+ result = match_uri_template(uri=uri, uri_template=uri_template)
580
+ assert result == expected_params
581
+
582
+ @pytest.mark.parametrize(
583
+ "uri, expected_params",
584
+ [
585
+ ("resource://prefix_foo_suffix", {"x": "foo"}),
586
+ ("resource://prefix_bar_suffix", {"x": "bar"}),
587
+ ("resource://prefix_hello_world_suffix", {"x": "hello_world"}),
588
+ ("resource://prefix__suffix", None), # Empty parameter not matched
589
+ ("resource://prefix_suffix", None), # Missing parameter delimiter
590
+ ("resource://other_foo_suffix", None), # Wrong prefix
591
+ ("resource://prefix_foo_other", None), # Wrong suffix
592
+ ],
593
+ )
594
+ def test_match_uri_template_embedded_param_with_prefix_and_suffix(
595
+ self, uri: str, expected_params: dict[str, str] | None
596
+ ):
597
+ """Test matching URIs where parameter has both prefix and suffix."""
598
+ uri_template = "resource://prefix_{x}_suffix"
599
+ result = match_uri_template(uri=uri, uri_template=uri_template)
600
+ assert result == expected_params
601
+
602
 
603
  class TestContextHandling:
604
  """Test context handling in resource templates."""
tests/server/test_server.py CHANGED
@@ -1235,3 +1235,106 @@ class TestResourcePrefixMounting:
1235
  "resource://imported/param-value/template"
1236
  )
1237
  assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1235
  "resource://imported/param-value/template"
1236
  )
1237
  assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
1238
+
1239
+
1240
+ class TestShouldIncludeComponent:
1241
+ def test_no_filters_returns_true(self):
1242
+ """Test that when no include or exclude filters are provided, always returns True."""
1243
+ tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1244
+ mcp = FastMCP(tools=[tool])
1245
+ result = mcp._should_enable_component(tool)
1246
+ assert result is True
1247
+
1248
+ def test_exclude_string_tag_present_returns_false(self):
1249
+ """Test that when an exclude string tag is present in tags, returns False."""
1250
+ tool = Tool(
1251
+ name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={}
1252
+ )
1253
+ mcp = FastMCP(tools=[tool], exclude_tags={"exclude_me"})
1254
+ result = mcp._should_enable_component(tool)
1255
+ assert result is False
1256
+
1257
+ def test_exclude_string_tag_absent_returns_true(self):
1258
+ """Test that when an exclude string tag is not present in tags, returns True."""
1259
+ tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1260
+ mcp = FastMCP(tools=[tool], exclude_tags={"exclude_me"})
1261
+ result = mcp._should_enable_component(tool)
1262
+ assert result is True
1263
+
1264
+ def test_multiple_exclude_tags_any_match_returns_false(self):
1265
+ """Test that when any exclude tag matches, returns False."""
1266
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1267
+ mcp = FastMCP(
1268
+ tools=[tool], exclude_tags={"not_present", "tag2", "also_not_present"}
1269
+ )
1270
+ result = mcp._should_enable_component(tool)
1271
+ assert result is False
1272
+
1273
+ def test_include_string_tag_present_returns_true(self):
1274
+ """Test that when an include string tag is present in tags, returns True."""
1275
+ tool = Tool(
1276
+ name="test_tool", tags={"tag1", "include_me", "tag2"}, parameters={}
1277
+ )
1278
+ mcp = FastMCP(tools=[tool], include_tags={"include_me"})
1279
+ result = mcp._should_enable_component(tool)
1280
+ assert result is True
1281
+
1282
+ def test_include_string_tag_absent_returns_false(self):
1283
+ """Test that when an include string tag is not present in tags, returns False."""
1284
+ tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1285
+ mcp = FastMCP(tools=[tool], include_tags={"include_me"})
1286
+ result = mcp._should_enable_component(tool)
1287
+ assert result is False
1288
+
1289
+ def test_multiple_include_tags_any_match_returns_true(self):
1290
+ """Test that when any include tag matches, returns True."""
1291
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1292
+ mcp = FastMCP(
1293
+ tools=[tool], include_tags={"not_present", "tag2", "also_not_present"}
1294
+ )
1295
+ result = mcp._should_enable_component(tool)
1296
+ assert result is True
1297
+
1298
+ def test_multiple_include_tags_none_match_returns_false(self):
1299
+ """Test that when no include tags match, returns False."""
1300
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1301
+ mcp = FastMCP(tools=[tool], include_tags={"not_present", "also_not_present"})
1302
+ result = mcp._should_enable_component(tool)
1303
+ assert result is False
1304
+
1305
+ def test_exclude_takes_precedence_over_include(self):
1306
+ """Test that exclude tags take precedence over include tags."""
1307
+ tool = Tool(
1308
+ name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={}
1309
+ )
1310
+ mcp = FastMCP(tools=[tool], include_tags={"tag1"}, exclude_tags={"exclude_me"})
1311
+ result = mcp._should_enable_component(tool)
1312
+ assert result is False
1313
+
1314
+ def test_empty_include_exclude_sets(self):
1315
+ """Test behavior with empty include/exclude sets."""
1316
+ # Empty include set means nothing matches
1317
+ tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1318
+ mcp1 = FastMCP(tools=[tool1], include_tags=set())
1319
+ result = mcp1._should_enable_component(tool1)
1320
+ assert result is False
1321
+
1322
+ # Empty exclude set means nothing excluded
1323
+ tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1324
+ mcp2 = FastMCP(tools=[tool2], exclude_tags=set())
1325
+ result = mcp2._should_enable_component(tool2)
1326
+ assert result is True
1327
+
1328
+ def test_empty_tags_with_filters(self):
1329
+ """Test behavior when input tags are empty."""
1330
+ # With include filters, empty tags should not match
1331
+ tool1 = Tool(name="test_tool", tags=set(), parameters={})
1332
+ mcp1 = FastMCP(tools=[tool1], include_tags={"required_tag"})
1333
+ result = mcp1._should_enable_component(tool1)
1334
+ assert result is False
1335
+
1336
+ # With exclude filters but no include, empty tags should pass
1337
+ tool2 = Tool(name="test_tool", tags=set(), parameters={})
1338
+ mcp2 = FastMCP(tools=[tool2], exclude_tags={"bad_tag"})
1339
+ result = mcp2._should_enable_component(tool2)
1340
+ assert result is True
tests/server/test_server_interactions.py CHANGED
@@ -115,6 +115,76 @@ class TestTools:
115
  assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  class TestToolReturnTypes:
119
  async def test_string(self):
120
  mcp = FastMCP()
@@ -865,6 +935,73 @@ class TestResource:
865
  assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
866
 
867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
868
  class TestResourceContext:
869
  async def test_resource_with_context_annotation_gets_context(self):
870
  mcp = FastMCP()
@@ -1196,6 +1333,76 @@ class TestResourceTemplates:
1196
  assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
1197
 
1198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1199
  class TestResourceTemplateContext:
1200
  async def test_resource_template_context(self):
1201
  mcp = FastMCP()
@@ -1631,3 +1838,73 @@ class TestPromptContext:
1631
  message = result.messages[0]
1632
  assert message.role == "user"
1633
  assert message.content.text == "Hello, World! 1" # type: ignore[attr-defined]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
116
 
117
 
118
+ class TestToolTags:
119
+ def create_server(self, include_tags=None, exclude_tags=None):
120
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
121
+
122
+ @mcp.tool(tags={"a", "b"})
123
+ def tool_1() -> int:
124
+ return 1
125
+
126
+ @mcp.tool(tags={"b", "c"})
127
+ def tool_2() -> int:
128
+ return 2
129
+
130
+ return mcp
131
+
132
+ async def test_include_tags_all_tools(self):
133
+ mcp = self.create_server(include_tags={"a", "b"})
134
+
135
+ async with Client(mcp) as client:
136
+ tools = await client.list_tools()
137
+ assert {t.name for t in tools} == {"tool_1", "tool_2"}
138
+
139
+ async def test_include_tags_some_tools(self):
140
+ mcp = self.create_server(include_tags={"a", "z"})
141
+
142
+ async with Client(mcp) as client:
143
+ tools = await client.list_tools()
144
+ assert {t.name for t in tools} == {"tool_1"}
145
+
146
+ async def test_exclude_tags_all_tools(self):
147
+ mcp = self.create_server(exclude_tags={"a", "b"})
148
+
149
+ async with Client(mcp) as client:
150
+ tools = await client.list_tools()
151
+ assert {t.name for t in tools} == set()
152
+
153
+ async def test_exclude_tags_some_tools(self):
154
+ mcp = self.create_server(exclude_tags={"a", "z"})
155
+
156
+ async with Client(mcp) as client:
157
+ tools = await client.list_tools()
158
+ assert {t.name for t in tools} == {"tool_2"}
159
+
160
+ async def test_exclude_precedence(self):
161
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
162
+
163
+ async with Client(mcp) as client:
164
+ tools = await client.list_tools()
165
+ assert {t.name for t in tools} == {"tool_2"}
166
+
167
+ async def test_call_included_tool(self):
168
+ mcp = self.create_server(include_tags={"a"})
169
+
170
+ async with Client(mcp) as client:
171
+ result_1 = await client.call_tool("tool_1", {})
172
+ assert result_1[0].text == "1" # type: ignore[attr-defined]
173
+
174
+ with pytest.raises(ToolError, match="Unknown tool"):
175
+ await client.call_tool("tool_2", {})
176
+
177
+ async def test_call_excluded_tool(self):
178
+ mcp = self.create_server(exclude_tags={"a"})
179
+
180
+ async with Client(mcp) as client:
181
+ with pytest.raises(ToolError, match="Unknown tool"):
182
+ await client.call_tool("tool_1", {})
183
+
184
+ result_2 = await client.call_tool("tool_2", {})
185
+ assert result_2[0].text == "2" # type: ignore[attr-defined]
186
+
187
+
188
  class TestToolReturnTypes:
189
  async def test_string(self):
190
  mcp = FastMCP()
 
935
  assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
936
 
937
 
938
+ class TestResourceTags:
939
+ def create_server(self, include_tags=None, exclude_tags=None):
940
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
941
+
942
+ @mcp.resource("resource://1", tags={"a", "b"})
943
+ def resource_1() -> str:
944
+ return "1"
945
+
946
+ @mcp.resource("resource://2", tags={"b", "c"})
947
+ def resource_2() -> str:
948
+ return "2"
949
+
950
+ return mcp
951
+
952
+ async def test_include_tags_all_resources(self):
953
+ mcp = self.create_server(include_tags={"a", "b"})
954
+
955
+ async with Client(mcp) as client:
956
+ resources = await client.list_resources()
957
+ assert {r.name for r in resources} == {"resource_1", "resource_2"}
958
+
959
+ async def test_include_tags_some_resources(self):
960
+ mcp = self.create_server(include_tags={"a", "z"})
961
+
962
+ async with Client(mcp) as client:
963
+ resources = await client.list_resources()
964
+ assert {r.name for r in resources} == {"resource_1"}
965
+
966
+ async def test_exclude_tags_all_resources(self):
967
+ mcp = self.create_server(exclude_tags={"a", "b"})
968
+
969
+ async with Client(mcp) as client:
970
+ resources = await client.list_resources()
971
+ assert {r.name for r in resources} == set()
972
+
973
+ async def test_exclude_tags_some_resources(self):
974
+ mcp = self.create_server(exclude_tags={"a", "z"})
975
+
976
+ async with Client(mcp) as client:
977
+ resources = await client.list_resources()
978
+ assert {r.name for r in resources} == {"resource_2"}
979
+
980
+ async def test_exclude_precedence(self):
981
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
982
+
983
+ async with Client(mcp) as client:
984
+ resources = await client.list_resources()
985
+ assert {r.name for r in resources} == {"resource_2"}
986
+
987
+ async def test_read_included_resource(self):
988
+ mcp = self.create_server(include_tags={"a"})
989
+
990
+ async with Client(mcp) as client:
991
+ result = await client.read_resource(AnyUrl("resource://1"))
992
+ assert result[0].text == "1" # type: ignore[attr-defined]
993
+
994
+ with pytest.raises(McpError, match="Unknown resource"):
995
+ await client.read_resource(AnyUrl("resource://2"))
996
+
997
+ async def test_read_excluded_resource(self):
998
+ mcp = self.create_server(exclude_tags={"a"})
999
+
1000
+ async with Client(mcp) as client:
1001
+ with pytest.raises(McpError, match="Unknown resource"):
1002
+ await client.read_resource(AnyUrl("resource://1"))
1003
+
1004
+
1005
  class TestResourceContext:
1006
  async def test_resource_with_context_annotation_gets_context(self):
1007
  mcp = FastMCP()
 
1333
  assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
1334
 
1335
 
1336
+ class TestResourceTemplatesTags:
1337
+ def create_server(self, include_tags=None, exclude_tags=None):
1338
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
1339
+
1340
+ @mcp.resource("resource://1/{param}", tags={"a", "b"})
1341
+ def template_resource_1(param: str) -> str:
1342
+ return f"Template resource 1: {param}"
1343
+
1344
+ @mcp.resource("resource://2/{param}", tags={"b", "c"})
1345
+ def template_resource_2(param: str) -> str:
1346
+ return f"Template resource 2: {param}"
1347
+
1348
+ return mcp
1349
+
1350
+ async def test_include_tags_all_resources(self):
1351
+ mcp = self.create_server(include_tags={"a", "b"})
1352
+
1353
+ async with Client(mcp) as client:
1354
+ resources = await client.list_resource_templates()
1355
+ assert {r.name for r in resources} == {
1356
+ "template_resource_1",
1357
+ "template_resource_2",
1358
+ }
1359
+
1360
+ async def test_include_tags_some_resources(self):
1361
+ mcp = self.create_server(include_tags={"a"})
1362
+
1363
+ async with Client(mcp) as client:
1364
+ resources = await client.list_resource_templates()
1365
+ assert {r.name for r in resources} == {"template_resource_1"}
1366
+
1367
+ async def test_exclude_tags_all_resources(self):
1368
+ mcp = self.create_server(exclude_tags={"a", "b"})
1369
+
1370
+ async with Client(mcp) as client:
1371
+ resources = await client.list_resource_templates()
1372
+ assert {r.name for r in resources} == set()
1373
+
1374
+ async def test_exclude_tags_some_resources(self):
1375
+ mcp = self.create_server(exclude_tags={"a"})
1376
+
1377
+ async with Client(mcp) as client:
1378
+ resources = await client.list_resource_templates()
1379
+ assert {r.name for r in resources} == {"template_resource_2"}
1380
+
1381
+ async def test_exclude_takes_precedence_over_include(self):
1382
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
1383
+
1384
+ async with Client(mcp) as client:
1385
+ resources = await client.list_resource_templates()
1386
+ assert {r.name for r in resources} == {"template_resource_2"}
1387
+
1388
+ async def test_read_resource_template_includes_tags(self):
1389
+ mcp = self.create_server(include_tags={"a"})
1390
+
1391
+ async with Client(mcp) as client:
1392
+ result = await client.read_resource("resource://1/x")
1393
+ assert result[0].text == "Template resource 1: x" # type: ignore[attr-defined]
1394
+
1395
+ with pytest.raises(McpError, match="Unknown resource"):
1396
+ await client.read_resource("resource://2/x")
1397
+
1398
+ async def test_read_resource_template_excludes_tags(self):
1399
+ mcp = self.create_server(exclude_tags={"a"})
1400
+
1401
+ async with Client(mcp) as client:
1402
+ with pytest.raises(McpError, match="Unknown resource"):
1403
+ await client.read_resource("resource://1/x")
1404
+
1405
+
1406
  class TestResourceTemplateContext:
1407
  async def test_resource_template_context(self):
1408
  mcp = FastMCP()
 
1838
  message = result.messages[0]
1839
  assert message.role == "user"
1840
  assert message.content.text == "Hello, World! 1" # type: ignore[attr-defined]
1841
+
1842
+
1843
+ class TestPromptTags:
1844
+ def create_server(self, include_tags=None, exclude_tags=None):
1845
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
1846
+
1847
+ @mcp.prompt(tags={"a", "b"})
1848
+ def prompt_1() -> str:
1849
+ return "1"
1850
+
1851
+ @mcp.prompt(tags={"b", "c"})
1852
+ def prompt_2() -> str:
1853
+ return "2"
1854
+
1855
+ return mcp
1856
+
1857
+ async def test_include_tags_all_prompts(self):
1858
+ mcp = self.create_server(include_tags={"a", "b"})
1859
+
1860
+ async with Client(mcp) as client:
1861
+ prompts = await client.list_prompts()
1862
+ assert {p.name for p in prompts} == {"prompt_1", "prompt_2"}
1863
+
1864
+ async def test_include_tags_some_prompts(self):
1865
+ mcp = self.create_server(include_tags={"a"})
1866
+
1867
+ async with Client(mcp) as client:
1868
+ prompts = await client.list_prompts()
1869
+ assert {p.name for p in prompts} == {"prompt_1"}
1870
+
1871
+ async def test_exclude_tags_all_prompts(self):
1872
+ mcp = self.create_server(exclude_tags={"a", "b"})
1873
+
1874
+ async with Client(mcp) as client:
1875
+ prompts = await client.list_prompts()
1876
+ assert {p.name for p in prompts} == set()
1877
+
1878
+ async def test_exclude_tags_some_prompts(self):
1879
+ mcp = self.create_server(exclude_tags={"a"})
1880
+
1881
+ async with Client(mcp) as client:
1882
+ prompts = await client.list_prompts()
1883
+ assert {p.name for p in prompts} == {"prompt_2"}
1884
+
1885
+ async def test_exclude_takes_precedence_over_include(self):
1886
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
1887
+
1888
+ async with Client(mcp) as client:
1889
+ prompts = await client.list_prompts()
1890
+ assert {p.name for p in prompts} == {"prompt_2"}
1891
+
1892
+ async def test_read_prompt_includes_tags(self):
1893
+ mcp = self.create_server(include_tags={"a"})
1894
+
1895
+ async with Client(mcp) as client:
1896
+ result = await client.get_prompt("prompt_1")
1897
+ assert result.messages[0].content.text == "1" # type: ignore[attr-defined]
1898
+
1899
+ with pytest.raises(McpError, match="Unknown prompt"):
1900
+ await client.get_prompt("prompt_2")
1901
+
1902
+ async def test_read_prompt_excludes_tags(self):
1903
+ mcp = self.create_server(exclude_tags={"a"})
1904
+
1905
+ async with Client(mcp) as client:
1906
+ with pytest.raises(McpError, match="Unknown prompt"):
1907
+ await client.get_prompt("prompt_1")
1908
+
1909
+ result = await client.get_prompt("prompt_2")
1910
+ assert result.messages[0].content.text == "2" # type: ignore[attr-defined]