Spaces:
Running
Running
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 +4 -2
- src/fastmcp/server/server.py +93 -13
- src/fastmcp/settings.py +44 -0
- src/fastmcp/tools/tool.py +1 -3
- tests/resources/test_resource_template.py +41 -0
- tests/server/test_server.py +228 -0
- tests/server/test_server_interactions.py +291 -0
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
|
| 44 |
"""Get prompt by key."""
|
| 45 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 383 |
-
|
| 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 |
-
|
| 394 |
-
|
| 395 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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]
|