Jeremiah Lowin commited on
Commit
1175ac0
·
1 Parent(s): df5ee4b

Add support for tag-based include/exclude

Browse files
src/fastmcp/prompts/prompt_manager.py CHANGED
@@ -40,9 +40,11 @@ class PromptManager:
40
 
41
  self.duplicate_behavior = duplicate_behavior
42
 
43
- def get_prompt(self, key: str) -> Prompt | None:
44
  """Get prompt by key."""
45
- return self._prompts.get(key)
 
 
46
 
47
  def get_prompts(self) -> dict[str, Prompt]:
48
  """Get all registered prompts, indexed by registered key."""
 
40
 
41
  self.duplicate_behavior = duplicate_behavior
42
 
43
+ def get_prompt(self, key: str) -> Prompt:
44
  """Get prompt by key."""
45
+ if key in self._prompts:
46
+ return self._prompts[key]
47
+ raise NotFoundError(f"Unknown prompt: {key}")
48
 
49
  def get_prompts(self) -> dict[str, Prompt]:
50
  """Get all registered prompts, indexed by registered key."""
src/fastmcp/server/server.py CHANGED
@@ -129,9 +129,24 @@ class FastMCP(Generic[LifespanResultT]):
129
  resource_prefix_format: Literal["protocol", "path"] | None = None,
130
  mask_error_details: bool | None = None,
131
  tools: list[Tool | Callable[..., Any]] | None = None,
 
 
 
 
 
 
 
 
132
  **settings: Any,
133
  ):
134
- self.settings = fastmcp.settings.ServerSettings(**settings)
 
 
 
 
 
 
 
135
 
136
  # If mask_error_details is provided, override the settings value
137
  if mask_error_details is not None:
@@ -146,6 +161,7 @@ class FastMCP(Generic[LifespanResultT]):
146
  self.resource_prefix_format = resource_prefix_format
147
 
148
  self.tags: set[str] = tags or set()
 
149
  self.dependencies = dependencies
150
  self._cache = TimedCache(
151
  expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0)
@@ -239,12 +255,12 @@ class FastMCP(Generic[LifespanResultT]):
239
  def _setup_handlers(self) -> None:
240
  """Set up core MCP protocol handlers."""
241
  self._mcp_server.list_tools()(self._mcp_list_tools)
242
- self._mcp_server.call_tool()(self._mcp_call_tool)
243
  self._mcp_server.list_resources()(self._mcp_list_resources)
244
- self._mcp_server.read_resource()(self._mcp_read_resource)
245
  self._mcp_server.list_prompts()(self._mcp_list_prompts)
 
 
246
  self._mcp_server.get_prompt()(self._mcp_get_prompt)
247
- self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
248
 
249
  async def get_tools(self) -> dict[str, Tool]:
250
  """Get all registered tools, indexed by registered key."""
@@ -370,7 +386,13 @@ class FastMCP(Generic[LifespanResultT]):
370
 
371
  """
372
  tools = await self.get_tools()
373
- return [tool.to_mcp_tool(name=key) for key, tool in tools.items()]
 
 
 
 
 
 
374
 
375
  async def _mcp_list_resources(self) -> list[MCPResource]:
376
  """
@@ -379,9 +401,11 @@ class FastMCP(Generic[LifespanResultT]):
379
 
380
  """
381
  resources = await self.get_resources()
382
- return [
383
- resource.to_mcp_resource(uri=key) for key, resource in resources.items()
384
- ]
 
 
385
 
386
  async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
387
  """
@@ -390,10 +414,11 @@ class FastMCP(Generic[LifespanResultT]):
390
 
391
  """
392
  templates = await self.get_resource_templates()
393
- return [
394
- template.to_mcp_template(uriTemplate=key)
395
- for key, template in templates.items()
396
- ]
 
397
 
398
  async def _mcp_list_prompts(self) -> list[MCPPrompt]:
399
  """
@@ -402,7 +427,11 @@ class FastMCP(Generic[LifespanResultT]):
402
 
403
  """
404
  prompts = await self.get_prompts()
405
- return [prompt.to_mcp_prompt(name=key) for key, prompt in prompts.items()]
 
 
 
 
406
 
407
  async def _mcp_call_tool(
408
  self, key: str, arguments: dict[str, Any]
@@ -422,6 +451,9 @@ class FastMCP(Generic[LifespanResultT]):
422
  with fastmcp.server.context.Context(fastmcp=self):
423
  # Get tool, checking first from our tools, then from the mounted servers
424
  if self._tool_manager.has_tool(key):
 
 
 
425
  return await self._tool_manager.call_tool(key, arguments)
426
 
427
  # Check mounted servers to see if they have the tool
@@ -440,6 +472,8 @@ class FastMCP(Generic[LifespanResultT]):
440
  with fastmcp.server.context.Context(fastmcp=self):
441
  if self._resource_manager.has_resource(uri):
442
  resource = await self._resource_manager.get_resource(uri)
 
 
443
  content = await self._resource_manager.read_resource(uri)
444
  return [
445
  ReadResourceContents(
@@ -473,6 +507,9 @@ class FastMCP(Generic[LifespanResultT]):
473
  with fastmcp.server.context.Context(fastmcp=self):
474
  # Get prompt, checking first from our prompts, then from the mounted servers
475
  if self._prompt_manager.has_prompt(name):
 
 
 
476
  return await self._prompt_manager.render_prompt(name, arguments)
477
 
478
  # Check mounted servers to see if they have the prompt
@@ -1506,6 +1543,49 @@ class FastMCP(Generic[LifespanResultT]):
1506
 
1507
  return cls.as_proxy(client, **settings)
1508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1509
 
1510
  class MountedServer:
1511
  def __init__(
 
129
  resource_prefix_format: Literal["protocol", "path"] | None = None,
130
  mask_error_details: bool | None = None,
131
  tools: list[Tool | Callable[..., Any]] | None = None,
132
+ include_tags: set[str]
133
+ | set[tuple[str, ...]]
134
+ | set[str | tuple[str, ...]]
135
+ | None = None,
136
+ exclude_tags: set[str]
137
+ | set[tuple[str, ...]]
138
+ | set[str | tuple[str, ...]]
139
+ | None = None,
140
  **settings: Any,
141
  ):
142
+ <<<<<<< Updated upstream
143
+ =======
144
+ if cache_expiration_seconds is not None:
145
+ settings["cache_expiration_seconds"] = cache_expiration_seconds
146
+ >>>>>>> Stashed changes
147
+ self.settings = fastmcp.settings.ServerSettings(
148
+ include_tags=include_tags, exclude_tags=exclude_tags, **settings
149
+ )
150
 
151
  # If mask_error_details is provided, override the settings value
152
  if mask_error_details is not None:
 
161
  self.resource_prefix_format = resource_prefix_format
162
 
163
  self.tags: set[str] = tags or set()
164
+
165
  self.dependencies = dependencies
166
  self._cache = TimedCache(
167
  expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0)
 
255
  def _setup_handlers(self) -> None:
256
  """Set up core MCP protocol handlers."""
257
  self._mcp_server.list_tools()(self._mcp_list_tools)
 
258
  self._mcp_server.list_resources()(self._mcp_list_resources)
259
+ self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
260
  self._mcp_server.list_prompts()(self._mcp_list_prompts)
261
+ self._mcp_server.call_tool()(self._mcp_call_tool)
262
+ self._mcp_server.read_resource()(self._mcp_read_resource)
263
  self._mcp_server.get_prompt()(self._mcp_get_prompt)
 
264
 
265
  async def get_tools(self) -> dict[str, Tool]:
266
  """Get all registered tools, indexed by registered key."""
 
386
 
387
  """
388
  tools = await self.get_tools()
389
+
390
+ mcp_tools: list[MCPTool] = []
391
+ for key, tool in tools.items():
392
+ if self.should_include_component(tool):
393
+ mcp_tools.append(tool.to_mcp_tool(name=key))
394
+
395
+ return mcp_tools
396
 
397
  async def _mcp_list_resources(self) -> list[MCPResource]:
398
  """
 
401
 
402
  """
403
  resources = await self.get_resources()
404
+ mcp_resources: list[MCPResource] = []
405
+ for key, resource in resources.items():
406
+ if self.should_include_component(resource):
407
+ mcp_resources.append(resource.to_mcp_resource(uri=key))
408
+ return mcp_resources
409
 
410
  async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
411
  """
 
414
 
415
  """
416
  templates = await self.get_resource_templates()
417
+ mcp_templates: list[MCPResourceTemplate] = []
418
+ for key, template in templates.items():
419
+ if self.should_include_component(template):
420
+ mcp_templates.append(template.to_mcp_template(uriTemplate=key))
421
+ return mcp_templates
422
 
423
  async def _mcp_list_prompts(self) -> list[MCPPrompt]:
424
  """
 
427
 
428
  """
429
  prompts = await self.get_prompts()
430
+ mcp_prompts: list[MCPPrompt] = []
431
+ for key, prompt in prompts.items():
432
+ if self.should_include_component(prompt):
433
+ mcp_prompts.append(prompt.to_mcp_prompt(name=key))
434
+ return mcp_prompts
435
 
436
  async def _mcp_call_tool(
437
  self, key: str, arguments: dict[str, Any]
 
451
  with fastmcp.server.context.Context(fastmcp=self):
452
  # Get tool, checking first from our tools, then from the mounted servers
453
  if self._tool_manager.has_tool(key):
454
+ tool = self._tool_manager.get_tool(key)
455
+ if not self.should_include_component(tool):
456
+ raise NotFoundError(f"Unknown tool: {key}")
457
  return await self._tool_manager.call_tool(key, arguments)
458
 
459
  # Check mounted servers to see if they have the tool
 
472
  with fastmcp.server.context.Context(fastmcp=self):
473
  if self._resource_manager.has_resource(uri):
474
  resource = await self._resource_manager.get_resource(uri)
475
+ if not self.should_include_component(resource):
476
+ raise NotFoundError(f"Unknown resource: {uri}")
477
  content = await self._resource_manager.read_resource(uri)
478
  return [
479
  ReadResourceContents(
 
507
  with fastmcp.server.context.Context(fastmcp=self):
508
  # Get prompt, checking first from our prompts, then from the mounted servers
509
  if self._prompt_manager.has_prompt(name):
510
+ prompt = self._prompt_manager.get_prompt(name)
511
+ if not self.should_include_component(prompt):
512
+ raise NotFoundError(f"Unknown prompt: {name}")
513
  return await self._prompt_manager.render_prompt(name, arguments)
514
 
515
  # Check mounted servers to see if they have the prompt
 
1543
 
1544
  return cls.as_proxy(client, **settings)
1545
 
1546
+ def should_include_component(
1547
+ self,
1548
+ component: Tool | Resource | ResourceTemplate | Prompt,
1549
+ ) -> bool:
1550
+ """
1551
+ Given a set of tags, determine if the tags match the include and exclude tags. Returns True if it should be included; False if it should not.
1552
+
1553
+ Rules:
1554
+ • If both include_tags and exclude_tags are None, return True.
1555
+ • If exclude_tags is provided, check each exclude tag:
1556
+ - If the exclude tag is a tuple, all tags in the tuple must be present in the input tags to exclude.
1557
+ - If the exclude tag is a string, it must be present in the input tags to exclude.
1558
+ • If include_tags is provided, check each include tag:
1559
+ - If the include tag is a tuple, all tags in the tuple must be present in the input tags to include.
1560
+ - If the include tag is a string, it must be present in the input tags to include.
1561
+ • If include_tags is provided and none of the include tags match, return False.
1562
+ • If include_tags is not provided, return True.
1563
+ """
1564
+ if self.settings.include_tags is None and self.settings.exclude_tags is None:
1565
+ return True
1566
+
1567
+ if self.settings.exclude_tags is not None:
1568
+ for etag in self.settings.exclude_tags:
1569
+ if isinstance(etag, tuple):
1570
+ if all(et in component.tags for et in etag):
1571
+ return False
1572
+ else:
1573
+ if etag in component.tags:
1574
+ return False
1575
+
1576
+ if self.settings.include_tags is not None:
1577
+ for itag in self.settings.include_tags:
1578
+ if isinstance(itag, tuple):
1579
+ if all(it in component.tags for it in itag):
1580
+ return True
1581
+ else:
1582
+ if itag in component.tags:
1583
+ return True
1584
+
1585
+ return False
1586
+ else:
1587
+ return True
1588
+
1589
 
1590
  class MountedServer:
1591
  def __init__(
src/fastmcp/settings.py CHANGED
@@ -198,5 +198,49 @@ class ServerSettings(BaseSettings):
198
  ),
199
  ] = None
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
  settings = Settings()
 
198
  ),
199
  ] = None
200
 
201
+ include_tags: Annotated[
202
+ set[str] | set[tuple[str, ...]] | set[str | tuple[str, ...]] | None,
203
+ Field(
204
+ default=None,
205
+ description=inspect.cleandoc(
206
+ """
207
+ If provided, only components that match these tags will be
208
+ exposed to clients. This can be a set of tags or tuples of tags.
209
+ A component is considered to match if ANY of its tags match ANY
210
+ of the tags in the set, or if any combination of its tags match
211
+ ALL of the tags in any tuple in the set.
212
+
213
+ For example, if include_tags is set to {"tag1", ("tag2",
214
+ "tag3")}, then a component with tags {"tag1", "tag4"} or
215
+ {"tag2", "tag3", "tag4"} will be included, but a component with
216
+ tags {"tag2", "tag4"} will not be included.
217
+ """
218
+ ),
219
+ ),
220
+ ] = None
221
+ exclude_tags: Annotated[
222
+ set[str] | set[tuple[str, ...]] | set[str | tuple[str, ...]] | None,
223
+ Field(
224
+ default=None,
225
+ description=inspect.cleandoc(
226
+ """
227
+ If provided, components that match these tags will be excluded
228
+ from the server. This can be a set of tags or tuples of tags.
229
+ This is applied after include_tags, so if a component matches
230
+ both include_tags and exclude_tags, it will be excluded.
231
+
232
+ A component is considered to match if ANY of its tags match ANY
233
+ of the tags in the set, or if any combination of its tags match
234
+ ALL of the tags in any tuple in the set.
235
+
236
+ For example, if exclude_tags is set to {"tag1", ("tag2",
237
+ "tag3")}, then a component with tags {"tag1", "tag4"} or
238
+ {"tag2", "tag3", "tag4"} will be excluded, but a component with
239
+ tags {"tag2", "tag4"} will not be excluded.
240
+ """
241
+ ),
242
+ ),
243
+ ] = None
244
+
245
 
246
  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 typing import TYPE_CHECKING, Annotated, Any
8
 
@@ -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(FastMCPBaseModel, ABC):
37
  """Internal tool registration info."""
38
 
39
  name: str = Field(description="Name of the tool")
@@ -91,7 +90,6 @@ class Tool(FastMCPBaseModel, ABC):
91
  assert isinstance(other, type(self))
92
  return self.model_dump() == other.model_dump()
93
 
94
- @abstractmethod
95
  async def run(
96
  self, arguments: dict[str, Any]
97
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
 
2
 
3
  import inspect
4
  import json
 
5
  from collections.abc import Callable
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
 
32
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
33
 
34
 
35
+ class Tool(FastMCPBaseModel):
36
  """Internal tool registration info."""
37
 
38
  name: str = Field(description="Name of the tool")
 
90
  assert isinstance(other, type(self))
91
  return self.model_dump() == other.model_dump()
92
 
 
93
  async def run(
94
  self, arguments: dict[str, Any]
95
  ) -> 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,231 @@ 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_include_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_include_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_include_component(tool)
1262
+ assert result is True
1263
+
1264
+ def test_exclude_tuple_all_present_returns_false(self):
1265
+ """Test that when all tags in exclude tuple are present, returns False."""
1266
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1267
+ mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")})
1268
+ result = mcp.should_include_component(tool)
1269
+ assert result is False
1270
+
1271
+ def test_exclude_tuple_partial_present_returns_true(self):
1272
+ """Test that when only some tags in exclude tuple are present, returns True."""
1273
+ tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={})
1274
+ mcp = FastMCP(tools=[tool], exclude_tags={("tag1", "tag2")})
1275
+ result = mcp.should_include_component(tool)
1276
+ assert result is True
1277
+
1278
+ def test_multiple_exclude_tags_any_match_returns_false(self):
1279
+ """Test that when any exclude tag matches, returns False."""
1280
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1281
+ mcp = FastMCP(
1282
+ tools=[tool], exclude_tags={"not_present", "tag2", "also_not_present"}
1283
+ )
1284
+ result = mcp.should_include_component(tool)
1285
+ assert result is False
1286
+
1287
+ def test_include_string_tag_present_returns_true(self):
1288
+ """Test that when an include string tag is present in tags, returns True."""
1289
+ tool = Tool(
1290
+ name="test_tool", tags={"tag1", "include_me", "tag2"}, parameters={}
1291
+ )
1292
+ mcp = FastMCP(tools=[tool], include_tags={"include_me"})
1293
+ result = mcp.should_include_component(tool)
1294
+ assert result is True
1295
+
1296
+ def test_include_string_tag_absent_returns_false(self):
1297
+ """Test that when an include string tag is not present in tags, returns False."""
1298
+ tool = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1299
+ mcp = FastMCP(tools=[tool], include_tags={"include_me"})
1300
+ result = mcp.should_include_component(tool)
1301
+ assert result is False
1302
+
1303
+ def test_include_tuple_all_present_returns_true(self):
1304
+ """Test that when all tags in include tuple are present, returns True."""
1305
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1306
+ mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")})
1307
+ result = mcp.should_include_component(tool)
1308
+ assert result is True
1309
+
1310
+ def test_include_tuple_partial_present_returns_false(self):
1311
+ """Test that when only some tags in include tuple are present, returns False."""
1312
+ tool = Tool(name="test_tool", tags={"tag1", "tag3"}, parameters={})
1313
+ mcp = FastMCP(tools=[tool], include_tags={("tag1", "tag2")})
1314
+ result = mcp.should_include_component(tool)
1315
+ assert result is False
1316
+
1317
+ def test_multiple_include_tags_any_match_returns_true(self):
1318
+ """Test that when any include tag matches, returns True."""
1319
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1320
+ mcp = FastMCP(
1321
+ tools=[tool], include_tags={"not_present", "tag2", "also_not_present"}
1322
+ )
1323
+ result = mcp.should_include_component(tool)
1324
+ assert result is True
1325
+
1326
+ def test_multiple_include_tags_none_match_returns_false(self):
1327
+ """Test that when no include tags match, returns False."""
1328
+ tool = Tool(name="test_tool", tags={"tag1", "tag2", "tag3"}, parameters={})
1329
+ mcp = FastMCP(tools=[tool], include_tags={"not_present", "also_not_present"})
1330
+ result = mcp.should_include_component(tool)
1331
+ assert result is False
1332
+
1333
+ def test_exclude_takes_precedence_over_include(self):
1334
+ """Test that exclude tags take precedence over include tags."""
1335
+ tool = Tool(
1336
+ name="test_tool", tags={"tag1", "tag2", "exclude_me"}, parameters={}
1337
+ )
1338
+ mcp = FastMCP(tools=[tool], include_tags={"tag1"}, exclude_tags={"exclude_me"})
1339
+ result = mcp.should_include_component(tool)
1340
+ assert result is False
1341
+
1342
+ def test_mixed_string_and_tuple_exclude_tags(self):
1343
+ """Test exclude tags with both string and tuple formats."""
1344
+ # Should be excluded because "tag1" is present
1345
+ tool1 = Tool(
1346
+ name="test_tool", tags={"tag1", "tag2", "tag3", "tag4"}, parameters={}
1347
+ )
1348
+ mcp1 = FastMCP(tools=[tool1], exclude_tags={"tag1", ("tag2", "tag3")})
1349
+ result = mcp1.should_include_component(tool1)
1350
+ assert result is False
1351
+
1352
+ # Remove tag1, should still be excluded because both tag2 and tag3 are present
1353
+ tool2 = Tool(name="test_tool", tags={"tag2", "tag3", "tag4"}, parameters={})
1354
+ mcp2 = FastMCP(tools=[tool2], exclude_tags={"tag1", ("tag2", "tag3")})
1355
+ result = mcp2.should_include_component(tool2)
1356
+ assert result is False
1357
+
1358
+ # Remove tag2, should not be excluded
1359
+ tool3 = Tool(
1360
+ name="test_tool", tags={"tag1_removed", "tag3", "tag4"}, parameters={}
1361
+ )
1362
+ mcp3 = FastMCP(tools=[tool3], exclude_tags={("tag2", "tag3")})
1363
+ result = mcp3.should_include_component(tool3)
1364
+ assert result is True
1365
+
1366
+ def test_mixed_string_and_tuple_include_tags(self):
1367
+ """Test include tags with both string and tuple formats."""
1368
+ # Should be included because both tag1 and tag2 are present (tuple match)
1369
+ tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1370
+ mcp1 = FastMCP(tools=[tool1], include_tags={"not_present", ("tag1", "tag2")})
1371
+ result = mcp1.should_include_component(tool1)
1372
+ assert result is True
1373
+
1374
+ # Should be included because tag1 is present (string match)
1375
+ tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1376
+ mcp2 = FastMCP(
1377
+ tools=[tool2], include_tags={"tag1", ("not_present1", "not_present2")}
1378
+ )
1379
+ result = mcp2.should_include_component(tool2)
1380
+ assert result is True
1381
+
1382
+ # Should not be included because no conditions are met
1383
+ tool3 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1384
+ mcp3 = FastMCP(
1385
+ tools=[tool3],
1386
+ include_tags={"not_present", ("not_present1", "not_present2")},
1387
+ )
1388
+ result = mcp3.should_include_component(tool3)
1389
+ assert result is False
1390
+
1391
+ def test_complex_scenario_with_both_filters(self):
1392
+ """Test complex scenario with both include and exclude filters."""
1393
+ # Should be excluded despite matching include conditions
1394
+ tool1 = Tool(
1395
+ name="test_tool", tags={"api", "read", "admin", "sensitive"}, parameters={}
1396
+ )
1397
+ mcp1 = FastMCP(
1398
+ tools=[tool1],
1399
+ include_tags={"api", ("read", "admin")},
1400
+ exclude_tags={"sensitive"},
1401
+ )
1402
+ result = mcp1.should_include_component(tool1)
1403
+ assert result is False
1404
+
1405
+ # Remove sensitive tag, should now be included
1406
+ tool2 = Tool(name="test_tool", tags={"api", "read", "admin"}, parameters={})
1407
+ mcp2 = FastMCP(
1408
+ tools=[tool2],
1409
+ include_tags={"api", ("read", "admin")},
1410
+ exclude_tags={"sensitive"},
1411
+ )
1412
+ result = mcp2.should_include_component(tool2)
1413
+ assert result is True
1414
+
1415
+ def test_empty_include_exclude_sets(self):
1416
+ """Test behavior with empty include/exclude sets."""
1417
+ # Empty include set means nothing matches
1418
+ tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1419
+ mcp1 = FastMCP(tools=[tool1], include_tags=set())
1420
+ result = mcp1.should_include_component(tool1)
1421
+ assert result is False
1422
+
1423
+ # Empty exclude set means nothing excluded
1424
+ tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1425
+ mcp2 = FastMCP(tools=[tool2], exclude_tags=set())
1426
+ result = mcp2.should_include_component(tool2)
1427
+ assert result is True
1428
+
1429
+ def test_empty_tags_with_filters(self):
1430
+ """Test behavior when input tags are empty."""
1431
+ # With include filters, empty tags should not match
1432
+ tool1 = Tool(name="test_tool", tags=set(), parameters={})
1433
+ mcp1 = FastMCP(tools=[tool1], include_tags={"required_tag"})
1434
+ result = mcp1.should_include_component(tool1)
1435
+ assert result is False
1436
+
1437
+ # With exclude filters but no include, empty tags should pass
1438
+ tool2 = Tool(name="test_tool", tags=set(), parameters={})
1439
+ mcp2 = FastMCP(tools=[tool2], exclude_tags={"bad_tag"})
1440
+ result = mcp2.should_include_component(tool2)
1441
+ assert result is True
1442
+
1443
+ # Tuple filters with empty tags
1444
+ tool3 = Tool(name="test_tool", tags=set(), parameters={})
1445
+ mcp3 = FastMCP(tools=[tool3], include_tags={("tag1", "tag2")})
1446
+ result = mcp3.should_include_component(tool3)
1447
+ assert result is False
1448
+
1449
+ tool4 = Tool(name="test_tool", tags=set(), parameters={})
1450
+ mcp4 = FastMCP(tools=[tool4], exclude_tags={("tag1", "tag2")})
1451
+ result = mcp4.should_include_component(tool4)
1452
+ assert result is True
1453
+
1454
+ def test_single_element_tuples(self):
1455
+ """Test behavior with single-element tuples."""
1456
+ # Single-element tuple should behave like a string
1457
+ tool1 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1458
+ mcp1 = FastMCP(tools=[tool1], include_tags={("tag1",)})
1459
+ result = mcp1.should_include_component(tool1)
1460
+ assert result is True
1461
+
1462
+ tool2 = Tool(name="test_tool", tags={"tag1", "tag2"}, parameters={})
1463
+ mcp2 = FastMCP(tools=[tool2], exclude_tags={("tag1",)})
1464
+ result = mcp2.should_include_component(tool2)
1465
+ assert result is False
tests/server/test_server_interactions.py CHANGED
@@ -115,6 +115,90 @@ 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()
@@ -769,6 +853,73 @@ class TestResource:
769
  assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
770
 
771
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  class TestResourceContext:
773
  async def test_resource_with_context_annotation_gets_context(self):
774
  mcp = FastMCP()
@@ -1004,6 +1155,76 @@ class TestResourceTemplates:
1004
  assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
1005
 
1006
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  class TestResourceTemplateContext:
1008
  async def test_resource_template_context(self):
1009
  mcp = FastMCP()
@@ -1250,3 +1471,73 @@ class TestPromptContext:
1250
  message = result.messages[0]
1251
  assert message.role == "user"
1252
  assert message.content.text == "Hello, World! 2" # 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_include_tags_tuple(self):
147
+ mcp = self.create_server(include_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} == {"tool_1"}
152
+
153
+ async def test_exclude_tags_all_tools(self):
154
+ mcp = self.create_server(exclude_tags={"a", "b"})
155
+
156
+ async with Client(mcp) as client:
157
+ tools = await client.list_tools()
158
+ assert {t.name for t in tools} == set()
159
+
160
+ async def test_exclude_tags_some_tools(self):
161
+ mcp = self.create_server(exclude_tags={"a", "z"})
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_exclude_tags_tuple(self):
168
+ mcp = self.create_server(exclude_tags={("a", "b")})
169
+
170
+ async with Client(mcp) as client:
171
+ tools = await client.list_tools()
172
+ assert {t.name for t in tools} == {"tool_2"}
173
+
174
+ async def test_exclude_precedence(self):
175
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
176
+
177
+ async with Client(mcp) as client:
178
+ tools = await client.list_tools()
179
+ assert {t.name for t in tools} == {"tool_2"}
180
+
181
+ async def test_call_included_tool(self):
182
+ mcp = self.create_server(include_tags={"a"})
183
+
184
+ async with Client(mcp) as client:
185
+ result_1 = await client.call_tool("tool_1", {})
186
+ assert result_1[0].text == "1" # type: ignore[attr-defined]
187
+
188
+ with pytest.raises(ToolError, match="Unknown tool"):
189
+ await client.call_tool("tool_2", {})
190
+
191
+ async def test_call_excluded_tool(self):
192
+ mcp = self.create_server(exclude_tags={"a"})
193
+
194
+ async with Client(mcp) as client:
195
+ with pytest.raises(ToolError, match="Unknown tool"):
196
+ await client.call_tool("tool_1", {})
197
+
198
+ result_2 = await client.call_tool("tool_2", {})
199
+ assert result_2[0].text == "2" # type: ignore[attr-defined]
200
+
201
+
202
  class TestToolReturnTypes:
203
  async def test_string(self):
204
  mcp = FastMCP()
 
853
  assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
854
 
855
 
856
+ class TestResourceTags:
857
+ def create_server(self, include_tags=None, exclude_tags=None):
858
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
859
+
860
+ @mcp.resource("resource://1", tags={"a", "b"})
861
+ def resource_1() -> str:
862
+ return "1"
863
+
864
+ @mcp.resource("resource://2", tags={"b", "c"})
865
+ def resource_2() -> str:
866
+ return "2"
867
+
868
+ return mcp
869
+
870
+ async def test_include_tags_all_resources(self):
871
+ mcp = self.create_server(include_tags={"a", "b"})
872
+
873
+ async with Client(mcp) as client:
874
+ resources = await client.list_resources()
875
+ assert {r.name for r in resources} == {"resource_1", "resource_2"}
876
+
877
+ async def test_include_tags_some_resources(self):
878
+ mcp = self.create_server(include_tags={"a", "z"})
879
+
880
+ async with Client(mcp) as client:
881
+ resources = await client.list_resources()
882
+ assert {r.name for r in resources} == {"resource_1"}
883
+
884
+ async def test_exclude_tags_all_resources(self):
885
+ mcp = self.create_server(exclude_tags={"a", "b"})
886
+
887
+ async with Client(mcp) as client:
888
+ resources = await client.list_resources()
889
+ assert {r.name for r in resources} == set()
890
+
891
+ async def test_exclude_tags_some_resources(self):
892
+ mcp = self.create_server(exclude_tags={"a", "z"})
893
+
894
+ async with Client(mcp) as client:
895
+ resources = await client.list_resources()
896
+ assert {r.name for r in resources} == {"resource_2"}
897
+
898
+ async def test_exclude_precedence(self):
899
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
900
+
901
+ async with Client(mcp) as client:
902
+ resources = await client.list_resources()
903
+ assert {r.name for r in resources} == {"resource_2"}
904
+
905
+ async def test_read_included_resource(self):
906
+ mcp = self.create_server(include_tags={"a"})
907
+
908
+ async with Client(mcp) as client:
909
+ result = await client.read_resource(AnyUrl("resource://1"))
910
+ assert result[0].text == "1" # type: ignore[attr-defined]
911
+
912
+ with pytest.raises(McpError, match="Unknown resource"):
913
+ await client.read_resource(AnyUrl("resource://2"))
914
+
915
+ async def test_read_excluded_resource(self):
916
+ mcp = self.create_server(exclude_tags={"a"})
917
+
918
+ async with Client(mcp) as client:
919
+ with pytest.raises(McpError, match="Unknown resource"):
920
+ await client.read_resource(AnyUrl("resource://1"))
921
+
922
+
923
  class TestResourceContext:
924
  async def test_resource_with_context_annotation_gets_context(self):
925
  mcp = FastMCP()
 
1155
  assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
1156
 
1157
 
1158
+ class TestResourceTemplatesTags:
1159
+ def create_server(self, include_tags=None, exclude_tags=None):
1160
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
1161
+
1162
+ @mcp.resource("resource://1/{param}", tags={"a", "b"})
1163
+ def template_resource_1(param: str) -> str:
1164
+ return f"Template resource 1: {param}"
1165
+
1166
+ @mcp.resource("resource://2/{param}", tags={"b", "c"})
1167
+ def template_resource_2(param: str) -> str:
1168
+ return f"Template resource 2: {param}"
1169
+
1170
+ return mcp
1171
+
1172
+ async def test_include_tags_all_resources(self):
1173
+ mcp = self.create_server(include_tags={"a", "b"})
1174
+
1175
+ async with Client(mcp) as client:
1176
+ resources = await client.list_resource_templates()
1177
+ assert {r.name for r in resources} == {
1178
+ "template_resource_1",
1179
+ "template_resource_2",
1180
+ }
1181
+
1182
+ async def test_include_tags_some_resources(self):
1183
+ mcp = self.create_server(include_tags={"a"})
1184
+
1185
+ async with Client(mcp) as client:
1186
+ resources = await client.list_resource_templates()
1187
+ assert {r.name for r in resources} == {"template_resource_1"}
1188
+
1189
+ async def test_exclude_tags_all_resources(self):
1190
+ mcp = self.create_server(exclude_tags={"a", "b"})
1191
+
1192
+ async with Client(mcp) as client:
1193
+ resources = await client.list_resource_templates()
1194
+ assert {r.name for r in resources} == set()
1195
+
1196
+ async def test_exclude_tags_some_resources(self):
1197
+ mcp = self.create_server(exclude_tags={"a"})
1198
+
1199
+ async with Client(mcp) as client:
1200
+ resources = await client.list_resource_templates()
1201
+ assert {r.name for r in resources} == {"template_resource_2"}
1202
+
1203
+ async def test_exclude_takes_precedence_over_include(self):
1204
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
1205
+
1206
+ async with Client(mcp) as client:
1207
+ resources = await client.list_resource_templates()
1208
+ assert {r.name for r in resources} == {"template_resource_2"}
1209
+
1210
+ async def test_read_resource_template_includes_tags(self):
1211
+ mcp = self.create_server(include_tags={"a"})
1212
+
1213
+ async with Client(mcp) as client:
1214
+ result = await client.read_resource("resource://1/x")
1215
+ assert result[0].text == "Template resource 1: x" # type: ignore[attr-defined]
1216
+
1217
+ with pytest.raises(McpError, match="Unknown resource"):
1218
+ await client.read_resource("resource://2/x")
1219
+
1220
+ async def test_read_resource_template_excludes_tags(self):
1221
+ mcp = self.create_server(exclude_tags={"a"})
1222
+
1223
+ async with Client(mcp) as client:
1224
+ with pytest.raises(McpError, match="Unknown resource"):
1225
+ await client.read_resource("resource://1/x")
1226
+
1227
+
1228
  class TestResourceTemplateContext:
1229
  async def test_resource_template_context(self):
1230
  mcp = FastMCP()
 
1471
  message = result.messages[0]
1472
  assert message.role == "user"
1473
  assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined]
1474
+
1475
+
1476
+ class TestPromptTags:
1477
+ def create_server(self, include_tags=None, exclude_tags=None):
1478
+ mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
1479
+
1480
+ @mcp.prompt(tags={"a", "b"})
1481
+ def prompt_1() -> str:
1482
+ return "1"
1483
+
1484
+ @mcp.prompt(tags={"b", "c"})
1485
+ def prompt_2() -> str:
1486
+ return "2"
1487
+
1488
+ return mcp
1489
+
1490
+ async def test_include_tags_all_prompts(self):
1491
+ mcp = self.create_server(include_tags={"a", "b"})
1492
+
1493
+ async with Client(mcp) as client:
1494
+ prompts = await client.list_prompts()
1495
+ assert {p.name for p in prompts} == {"prompt_1", "prompt_2"}
1496
+
1497
+ async def test_include_tags_some_prompts(self):
1498
+ mcp = self.create_server(include_tags={"a"})
1499
+
1500
+ async with Client(mcp) as client:
1501
+ prompts = await client.list_prompts()
1502
+ assert {p.name for p in prompts} == {"prompt_1"}
1503
+
1504
+ async def test_exclude_tags_all_prompts(self):
1505
+ mcp = self.create_server(exclude_tags={"a", "b"})
1506
+
1507
+ async with Client(mcp) as client:
1508
+ prompts = await client.list_prompts()
1509
+ assert {p.name for p in prompts} == set()
1510
+
1511
+ async def test_exclude_tags_some_prompts(self):
1512
+ mcp = self.create_server(exclude_tags={"a"})
1513
+
1514
+ async with Client(mcp) as client:
1515
+ prompts = await client.list_prompts()
1516
+ assert {p.name for p in prompts} == {"prompt_2"}
1517
+
1518
+ async def test_exclude_takes_precedence_over_include(self):
1519
+ mcp = self.create_server(exclude_tags={"a"}, include_tags={"b"})
1520
+
1521
+ async with Client(mcp) as client:
1522
+ prompts = await client.list_prompts()
1523
+ assert {p.name for p in prompts} == {"prompt_2"}
1524
+
1525
+ async def test_read_prompt_includes_tags(self):
1526
+ mcp = self.create_server(include_tags={"a"})
1527
+
1528
+ async with Client(mcp) as client:
1529
+ result = await client.get_prompt("prompt_1")
1530
+ assert result.messages[0].content.text == "1" # type: ignore[attr-defined]
1531
+
1532
+ with pytest.raises(McpError, match="Unknown prompt"):
1533
+ await client.get_prompt("prompt_2")
1534
+
1535
+ async def test_read_prompt_excludes_tags(self):
1536
+ mcp = self.create_server(exclude_tags={"a"})
1537
+
1538
+ async with Client(mcp) as client:
1539
+ with pytest.raises(McpError, match="Unknown prompt"):
1540
+ await client.get_prompt("prompt_1")
1541
+
1542
+ result = await client.get_prompt("prompt_2")
1543
+ assert result.messages[0].content.text == "2" # type: ignore[attr-defined]