Spaces:
Running
Running
Merge pull request #121 from jlowin/tags
Browse files- .cursor/rules/core-mcp-objects.mdc +13 -0
- src/fastmcp/prompts/__init__.py +1 -1
- src/fastmcp/prompts/{base.py → prompt.py} +23 -3
- src/fastmcp/prompts/prompt_manager.py +27 -9
- src/fastmcp/resources/__init__.py +2 -2
- src/fastmcp/resources/{base.py → resource.py} +20 -1
- src/fastmcp/resources/resource_manager.py +56 -10
- src/fastmcp/resources/{templates.py → template.py} +23 -3
- src/fastmcp/resources/types.py +2 -2
- src/fastmcp/server/openapi.py +1 -1
- src/fastmcp/server/proxy.py +1 -1
- src/fastmcp/server/server.py +27 -9
- src/fastmcp/settings.py +11 -3
- src/fastmcp/tools/__init__.py +1 -1
- src/fastmcp/tools/{base.py → tool.py} +22 -3
- src/fastmcp/tools/tool_manager.py +21 -15
- src/fastmcp/utilities/types.py +12 -0
- tests/prompts/test_base.py +1 -1
- tests/prompts/test_prompt_manager.py +141 -81
- tests/resources/test_resource_manager.py +196 -2
- tests/server/test_server.py +2 -2
- tests/tools/test_tool_manager.py +158 -42
.cursor/rules/core-mcp-objects.mdc
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description:
|
| 3 |
+
globs:
|
| 4 |
+
alwaysApply: true
|
| 5 |
+
---
|
| 6 |
+
There are four major MCP object types:
|
| 7 |
+
|
| 8 |
+
- Tools (src/tools/)
|
| 9 |
+
- Resources (src/resources/)
|
| 10 |
+
- Resource Templates (src/resources/)
|
| 11 |
+
- Prompts (src/prompts)
|
| 12 |
+
|
| 13 |
+
While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Be sure to look at not only the object definition but also the related `Manager` (e.g. `ToolManager`, `ResourceManager`, and `PromptManager`). Also note that while resources and resource templates are different objects, they both are handled by the `ResourceManager`.
|
src/fastmcp/prompts/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from .
|
| 2 |
from .prompt_manager import PromptManager
|
| 3 |
|
| 4 |
__all__ = ["Prompt", "PromptManager"]
|
|
|
|
| 1 |
+
from .prompt import Prompt
|
| 2 |
from .prompt_manager import PromptManager
|
| 3 |
|
| 4 |
__all__ = ["Prompt", "PromptManager"]
|
src/fastmcp/prompts/{base.py → prompt.py}
RENAMED
|
@@ -3,11 +3,14 @@
|
|
| 3 |
import inspect
|
| 4 |
import json
|
| 5 |
from collections.abc import Awaitable, Callable, Sequence
|
| 6 |
-
from typing import Any, Literal
|
| 7 |
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
| 10 |
-
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
| 13 |
|
|
@@ -71,10 +74,13 @@ class Prompt(BaseModel):
|
|
| 71 |
description: str | None = Field(
|
| 72 |
None, description="Description of what the prompt does"
|
| 73 |
)
|
|
|
|
|
|
|
|
|
|
| 74 |
arguments: list[PromptArgument] | None = Field(
|
| 75 |
None, description="Arguments that can be passed to the prompt"
|
| 76 |
)
|
| 77 |
-
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
|
| 78 |
|
| 79 |
@classmethod
|
| 80 |
def from_function(
|
|
@@ -82,6 +88,7 @@ class Prompt(BaseModel):
|
|
| 82 |
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
| 83 |
name: str | None = None,
|
| 84 |
description: str | None = None,
|
|
|
|
| 85 |
) -> "Prompt":
|
| 86 |
"""Create a Prompt from a function.
|
| 87 |
|
|
@@ -120,6 +127,7 @@ class Prompt(BaseModel):
|
|
| 120 |
description=description or fn.__doc__ or "",
|
| 121 |
arguments=arguments,
|
| 122 |
fn=fn,
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]:
|
|
@@ -164,3 +172,15 @@ class Prompt(BaseModel):
|
|
| 164 |
return messages
|
| 165 |
except Exception as e:
|
| 166 |
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import inspect
|
| 4 |
import json
|
| 5 |
from collections.abc import Awaitable, Callable, Sequence
|
| 6 |
+
from typing import Annotated, Any, Literal
|
| 7 |
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
| 10 |
+
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
| 11 |
+
from typing_extensions import Self
|
| 12 |
+
|
| 13 |
+
from fastmcp.utilities.types import _convert_set_defaults
|
| 14 |
|
| 15 |
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
| 16 |
|
|
|
|
| 74 |
description: str | None = Field(
|
| 75 |
None, description="Description of what the prompt does"
|
| 76 |
)
|
| 77 |
+
tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
|
| 78 |
+
default_factory=set, description="Tags for the prompt"
|
| 79 |
+
)
|
| 80 |
arguments: list[PromptArgument] | None = Field(
|
| 81 |
None, description="Arguments that can be passed to the prompt"
|
| 82 |
)
|
| 83 |
+
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
|
| 84 |
|
| 85 |
@classmethod
|
| 86 |
def from_function(
|
|
|
|
| 88 |
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
| 89 |
name: str | None = None,
|
| 90 |
description: str | None = None,
|
| 91 |
+
tags: set[str] | None = None,
|
| 92 |
) -> "Prompt":
|
| 93 |
"""Create a Prompt from a function.
|
| 94 |
|
|
|
|
| 127 |
description=description or fn.__doc__ or "",
|
| 128 |
arguments=arguments,
|
| 129 |
fn=fn,
|
| 130 |
+
tags=tags or set(),
|
| 131 |
)
|
| 132 |
|
| 133 |
async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]:
|
|
|
|
| 172 |
return messages
|
| 173 |
except Exception as e:
|
| 174 |
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
| 175 |
+
|
| 176 |
+
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 177 |
+
"""Copy the prompt with optional updates."""
|
| 178 |
+
data = self.model_dump()
|
| 179 |
+
if updates:
|
| 180 |
+
data.update(updates)
|
| 181 |
+
return type(self)(**data)
|
| 182 |
+
|
| 183 |
+
def __eq__(self, other: object) -> bool:
|
| 184 |
+
if not isinstance(other, Prompt):
|
| 185 |
+
return False
|
| 186 |
+
return self.model_dump() == other.model_dump()
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
"""Prompt management functionality."""
|
| 2 |
|
|
|
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from fastmcp.prompts.
|
|
|
|
| 6 |
from fastmcp.utilities.logging import get_logger
|
| 7 |
|
| 8 |
logger = get_logger(__name__)
|
|
@@ -11,9 +13,9 @@ logger = get_logger(__name__)
|
|
| 11 |
class PromptManager:
|
| 12 |
"""Manages FastMCP prompts."""
|
| 13 |
|
| 14 |
-
def __init__(self,
|
| 15 |
self._prompts: dict[str, Prompt] = {}
|
| 16 |
-
self.
|
| 17 |
|
| 18 |
def get_prompt(self, name: str) -> Prompt | None:
|
| 19 |
"""Get prompt by name."""
|
|
@@ -23,18 +25,32 @@ class PromptManager:
|
|
| 23 |
"""List all registered prompts."""
|
| 24 |
return list(self._prompts.values())
|
| 25 |
|
| 26 |
-
def
|
| 27 |
self,
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
) -> Prompt:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
"""Add a prompt to the manager."""
|
| 31 |
|
| 32 |
# Check for duplicates
|
| 33 |
existing = self._prompts.get(prompt.name)
|
| 34 |
if existing:
|
| 35 |
-
if self.
|
| 36 |
logger.warning(f"Prompt already exists: {prompt.name}")
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
self._prompts[prompt.name] = prompt
|
| 40 |
return prompt
|
|
@@ -64,11 +80,13 @@ class PromptManager:
|
|
| 64 |
the imported prompt would be available as "weather/forecast_prompt"
|
| 65 |
"""
|
| 66 |
for name, prompt in manager._prompts.items():
|
| 67 |
-
# Create prefixed name
|
| 68 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 69 |
|
|
|
|
|
|
|
| 70 |
# Log the import
|
| 71 |
logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
|
| 72 |
|
| 73 |
# Store the prompt with the prefixed name
|
| 74 |
-
self.
|
|
|
|
| 1 |
"""Prompt management functionality."""
|
| 2 |
|
| 3 |
+
from collections.abc import Awaitable, Callable
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
+
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
| 7 |
+
from fastmcp.settings import DuplicateBehavior
|
| 8 |
from fastmcp.utilities.logging import get_logger
|
| 9 |
|
| 10 |
logger = get_logger(__name__)
|
|
|
|
| 13 |
class PromptManager:
|
| 14 |
"""Manages FastMCP prompts."""
|
| 15 |
|
| 16 |
+
def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
|
| 17 |
self._prompts: dict[str, Prompt] = {}
|
| 18 |
+
self.duplicate_behavior = duplicate_behavior
|
| 19 |
|
| 20 |
def get_prompt(self, name: str) -> Prompt | None:
|
| 21 |
"""Get prompt by name."""
|
|
|
|
| 25 |
"""List all registered prompts."""
|
| 26 |
return list(self._prompts.values())
|
| 27 |
|
| 28 |
+
def add_prompt_from_fn(
|
| 29 |
self,
|
| 30 |
+
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
| 31 |
+
name: str | None = None,
|
| 32 |
+
description: str | None = None,
|
| 33 |
+
tags: set[str] | None = None,
|
| 34 |
) -> Prompt:
|
| 35 |
+
"""Create a prompt from a function."""
|
| 36 |
+
prompt = Prompt.from_function(fn, name=name, description=description, tags=tags)
|
| 37 |
+
return self.add_prompt(prompt)
|
| 38 |
+
|
| 39 |
+
def add_prompt(self, prompt: Prompt) -> Prompt:
|
| 40 |
"""Add a prompt to the manager."""
|
| 41 |
|
| 42 |
# Check for duplicates
|
| 43 |
existing = self._prompts.get(prompt.name)
|
| 44 |
if existing:
|
| 45 |
+
if self.duplicate_behavior == DuplicateBehavior.WARN:
|
| 46 |
logger.warning(f"Prompt already exists: {prompt.name}")
|
| 47 |
+
self._prompts[prompt.name] = prompt
|
| 48 |
+
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
|
| 49 |
+
self._prompts[prompt.name] = prompt
|
| 50 |
+
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
|
| 51 |
+
raise ValueError(f"Prompt already exists: {prompt.name}")
|
| 52 |
+
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
|
| 53 |
+
pass
|
| 54 |
|
| 55 |
self._prompts[prompt.name] = prompt
|
| 56 |
return prompt
|
|
|
|
| 80 |
the imported prompt would be available as "weather/forecast_prompt"
|
| 81 |
"""
|
| 82 |
for name, prompt in manager._prompts.items():
|
| 83 |
+
# Create prefixed name
|
| 84 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 85 |
|
| 86 |
+
new_prompt = prompt.copy(updates=dict(name=prefixed_name))
|
| 87 |
+
|
| 88 |
# Log the import
|
| 89 |
logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
|
| 90 |
|
| 91 |
# Store the prompt with the prefixed name
|
| 92 |
+
self.add_prompt(new_prompt)
|
src/fastmcp/resources/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
from .
|
| 2 |
from .resource_manager import ResourceManager
|
| 3 |
-
from .
|
| 4 |
from .types import (
|
| 5 |
BinaryResource,
|
| 6 |
DirectoryResource,
|
|
|
|
| 1 |
+
from .resource import Resource
|
| 2 |
from .resource_manager import ResourceManager
|
| 3 |
+
from .template import ResourceTemplate
|
| 4 |
from .types import (
|
| 5 |
BinaryResource,
|
| 6 |
DirectoryResource,
|
src/fastmcp/resources/{base.py → resource.py}
RENAMED
|
@@ -1,17 +1,21 @@
|
|
| 1 |
"""Base classes and interfaces for FastMCP resources."""
|
| 2 |
|
| 3 |
import abc
|
| 4 |
-
from typing import Annotated
|
| 5 |
|
| 6 |
from pydantic import (
|
| 7 |
AnyUrl,
|
| 8 |
BaseModel,
|
|
|
|
| 9 |
ConfigDict,
|
| 10 |
Field,
|
| 11 |
UrlConstraints,
|
| 12 |
ValidationInfo,
|
| 13 |
field_validator,
|
| 14 |
)
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class Resource(BaseModel, abc.ABC):
|
|
@@ -26,6 +30,9 @@ class Resource(BaseModel, abc.ABC):
|
|
| 26 |
description: str | None = Field(
|
| 27 |
description="Description of the resource", default=None
|
| 28 |
)
|
|
|
|
|
|
|
|
|
|
| 29 |
mime_type: str = Field(
|
| 30 |
default="text/plain",
|
| 31 |
description="MIME type of the resource content",
|
|
@@ -46,3 +53,15 @@ class Resource(BaseModel, abc.ABC):
|
|
| 46 |
async def read(self) -> str | bytes:
|
| 47 |
"""Read the resource content."""
|
| 48 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Base classes and interfaces for FastMCP resources."""
|
| 2 |
|
| 3 |
import abc
|
| 4 |
+
from typing import Annotated, Any
|
| 5 |
|
| 6 |
from pydantic import (
|
| 7 |
AnyUrl,
|
| 8 |
BaseModel,
|
| 9 |
+
BeforeValidator,
|
| 10 |
ConfigDict,
|
| 11 |
Field,
|
| 12 |
UrlConstraints,
|
| 13 |
ValidationInfo,
|
| 14 |
field_validator,
|
| 15 |
)
|
| 16 |
+
from typing_extensions import Self
|
| 17 |
+
|
| 18 |
+
from fastmcp.utilities.types import _convert_set_defaults
|
| 19 |
|
| 20 |
|
| 21 |
class Resource(BaseModel, abc.ABC):
|
|
|
|
| 30 |
description: str | None = Field(
|
| 31 |
description="Description of the resource", default=None
|
| 32 |
)
|
| 33 |
+
tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
|
| 34 |
+
default_factory=set, description="Tags for the resource"
|
| 35 |
+
)
|
| 36 |
mime_type: str = Field(
|
| 37 |
default="text/plain",
|
| 38 |
description="MIME type of the resource content",
|
|
|
|
| 53 |
async def read(self) -> str | bytes:
|
| 54 |
"""Read the resource content."""
|
| 55 |
pass
|
| 56 |
+
|
| 57 |
+
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 58 |
+
"""Copy the resource with optional updates."""
|
| 59 |
+
data = self.model_dump()
|
| 60 |
+
if updates:
|
| 61 |
+
data.update(updates)
|
| 62 |
+
return type(self)(**data)
|
| 63 |
+
|
| 64 |
+
def __eq__(self, other: object) -> bool:
|
| 65 |
+
if not isinstance(other, Resource):
|
| 66 |
+
return False
|
| 67 |
+
return self.model_dump() == other.model_dump()
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -5,8 +5,9 @@ from typing import Any
|
|
| 5 |
|
| 6 |
from pydantic import AnyUrl
|
| 7 |
|
| 8 |
-
from fastmcp.resources.
|
| 9 |
-
from fastmcp.resources.
|
|
|
|
| 10 |
from fastmcp.utilities.logging import get_logger
|
| 11 |
|
| 12 |
logger = get_logger(__name__)
|
|
@@ -15,10 +16,10 @@ logger = get_logger(__name__)
|
|
| 15 |
class ResourceManager:
|
| 16 |
"""Manages FastMCP resources."""
|
| 17 |
|
| 18 |
-
def __init__(self,
|
| 19 |
self._resources: dict[str, Resource] = {}
|
| 20 |
self._templates: dict[str, ResourceTemplate] = {}
|
| 21 |
-
self.
|
| 22 |
|
| 23 |
def add_resource(self, resource: Resource) -> Resource:
|
| 24 |
"""Add a resource to the manager.
|
|
@@ -40,28 +41,67 @@ class ResourceManager:
|
|
| 40 |
)
|
| 41 |
existing = self._resources.get(str(resource.uri))
|
| 42 |
if existing:
|
| 43 |
-
if self.
|
| 44 |
logger.warning(f"Resource already exists: {resource.uri}")
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
self._resources[str(resource.uri)] = resource
|
| 47 |
return resource
|
| 48 |
|
| 49 |
-
def
|
| 50 |
self,
|
| 51 |
fn: Callable[..., Any],
|
| 52 |
uri_template: str,
|
| 53 |
name: str | None = None,
|
| 54 |
description: str | None = None,
|
| 55 |
mime_type: str | None = None,
|
|
|
|
| 56 |
) -> ResourceTemplate:
|
| 57 |
-
"""
|
| 58 |
template = ResourceTemplate.from_function(
|
| 59 |
fn,
|
| 60 |
uri_template=uri_template,
|
| 61 |
name=name,
|
| 62 |
description=description,
|
| 63 |
mime_type=mime_type,
|
|
|
|
| 64 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
self._templates[template.uri_template] = template
|
| 66 |
return template
|
| 67 |
|
|
@@ -114,11 +154,13 @@ class ResourceManager:
|
|
| 114 |
# Create prefixed URI and copy the resource with the new URI
|
| 115 |
prefixed_uri = f"{prefix}{uri}" if prefix else uri
|
| 116 |
|
|
|
|
|
|
|
| 117 |
# Log the import
|
| 118 |
logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
|
| 119 |
|
| 120 |
# Store directly in resources dictionary
|
| 121 |
-
self.
|
| 122 |
|
| 123 |
def import_templates(
|
| 124 |
self, manager: "ResourceManager", prefix: str | None = None
|
|
@@ -142,10 +184,14 @@ class ResourceManager:
|
|
| 142 |
f"{prefix}{uri_template}" if prefix else uri_template
|
| 143 |
)
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
# Log the import
|
| 146 |
logger.debug(
|
| 147 |
f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
|
| 148 |
)
|
| 149 |
|
| 150 |
# Store directly in templates dictionary
|
| 151 |
-
self.
|
|
|
|
| 5 |
|
| 6 |
from pydantic import AnyUrl
|
| 7 |
|
| 8 |
+
from fastmcp.resources.resource import Resource
|
| 9 |
+
from fastmcp.resources.template import ResourceTemplate
|
| 10 |
+
from fastmcp.settings import DuplicateBehavior
|
| 11 |
from fastmcp.utilities.logging import get_logger
|
| 12 |
|
| 13 |
logger = get_logger(__name__)
|
|
|
|
| 16 |
class ResourceManager:
|
| 17 |
"""Manages FastMCP resources."""
|
| 18 |
|
| 19 |
+
def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
|
| 20 |
self._resources: dict[str, Resource] = {}
|
| 21 |
self._templates: dict[str, ResourceTemplate] = {}
|
| 22 |
+
self.duplicate_behavior = duplicate_behavior
|
| 23 |
|
| 24 |
def add_resource(self, resource: Resource) -> Resource:
|
| 25 |
"""Add a resource to the manager.
|
|
|
|
| 41 |
)
|
| 42 |
existing = self._resources.get(str(resource.uri))
|
| 43 |
if existing:
|
| 44 |
+
if self.duplicate_behavior == DuplicateBehavior.WARN:
|
| 45 |
logger.warning(f"Resource already exists: {resource.uri}")
|
| 46 |
+
self._resources[str(resource.uri)] = resource
|
| 47 |
+
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
|
| 48 |
+
self._resources[str(resource.uri)] = resource
|
| 49 |
+
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
|
| 50 |
+
raise ValueError(f"Resource already exists: {resource.uri}")
|
| 51 |
+
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
|
| 52 |
+
pass
|
| 53 |
self._resources[str(resource.uri)] = resource
|
| 54 |
return resource
|
| 55 |
|
| 56 |
+
def add_template_from_fn(
|
| 57 |
self,
|
| 58 |
fn: Callable[..., Any],
|
| 59 |
uri_template: str,
|
| 60 |
name: str | None = None,
|
| 61 |
description: str | None = None,
|
| 62 |
mime_type: str | None = None,
|
| 63 |
+
tags: set[str] | None = None,
|
| 64 |
) -> ResourceTemplate:
|
| 65 |
+
"""Create a template from a function."""
|
| 66 |
template = ResourceTemplate.from_function(
|
| 67 |
fn,
|
| 68 |
uri_template=uri_template,
|
| 69 |
name=name,
|
| 70 |
description=description,
|
| 71 |
mime_type=mime_type,
|
| 72 |
+
tags=tags,
|
| 73 |
)
|
| 74 |
+
return self.add_template(template)
|
| 75 |
+
|
| 76 |
+
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
|
| 77 |
+
"""Add a template to the manager.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
template: A ResourceTemplate instance to add
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
The added template. If a template with the same URI already exists,
|
| 84 |
+
returns the existing template.
|
| 85 |
+
"""
|
| 86 |
+
logger.debug(
|
| 87 |
+
"Adding resource",
|
| 88 |
+
extra={
|
| 89 |
+
"uri": template.uri_template,
|
| 90 |
+
"type": type(template).__name__,
|
| 91 |
+
"resource_name": template.name,
|
| 92 |
+
},
|
| 93 |
+
)
|
| 94 |
+
existing = self._templates.get(str(template.uri_template))
|
| 95 |
+
if existing:
|
| 96 |
+
if self.duplicate_behavior == DuplicateBehavior.WARN:
|
| 97 |
+
logger.warning(f"Resource already exists: {template.uri_template}")
|
| 98 |
+
self._templates[str(template.uri_template)] = template
|
| 99 |
+
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
|
| 100 |
+
self._templates[str(template.uri_template)] = template
|
| 101 |
+
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
|
| 102 |
+
raise ValueError(f"Resource already exists: {template.uri_template}")
|
| 103 |
+
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
|
| 104 |
+
pass
|
| 105 |
self._templates[template.uri_template] = template
|
| 106 |
return template
|
| 107 |
|
|
|
|
| 154 |
# Create prefixed URI and copy the resource with the new URI
|
| 155 |
prefixed_uri = f"{prefix}{uri}" if prefix else uri
|
| 156 |
|
| 157 |
+
new_resource = resource.copy(updates=dict(uri=prefixed_uri))
|
| 158 |
+
|
| 159 |
# Log the import
|
| 160 |
logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
|
| 161 |
|
| 162 |
# Store directly in resources dictionary
|
| 163 |
+
self.add_resource(new_resource)
|
| 164 |
|
| 165 |
def import_templates(
|
| 166 |
self, manager: "ResourceManager", prefix: str | None = None
|
|
|
|
| 184 |
f"{prefix}{uri_template}" if prefix else uri_template
|
| 185 |
)
|
| 186 |
|
| 187 |
+
new_template = template.copy(
|
| 188 |
+
updates=dict(uri_template=prefixed_uri_template)
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
# Log the import
|
| 192 |
logger.debug(
|
| 193 |
f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
|
| 194 |
)
|
| 195 |
|
| 196 |
# Store directly in templates dictionary
|
| 197 |
+
self.add_template(new_template)
|
src/fastmcp/resources/{templates.py → template.py}
RENAMED
|
@@ -5,11 +5,13 @@ from __future__ import annotations
|
|
| 5 |
import inspect
|
| 6 |
import re
|
| 7 |
from collections.abc import Callable
|
| 8 |
-
from typing import Any
|
| 9 |
|
| 10 |
-
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
|
|
|
| 11 |
|
| 12 |
from fastmcp.resources.types import FunctionResource, Resource
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class ResourceTemplate(BaseModel):
|
|
@@ -20,10 +22,13 @@ class ResourceTemplate(BaseModel):
|
|
| 20 |
)
|
| 21 |
name: str = Field(description="Name of the resource")
|
| 22 |
description: str | None = Field(description="Description of what the resource does")
|
|
|
|
|
|
|
|
|
|
| 23 |
mime_type: str = Field(
|
| 24 |
default="text/plain", description="MIME type of the resource content"
|
| 25 |
)
|
| 26 |
-
fn: Callable[..., Any]
|
| 27 |
parameters: dict[str, Any] = Field(
|
| 28 |
description="JSON schema for function parameters"
|
| 29 |
)
|
|
@@ -36,6 +41,7 @@ class ResourceTemplate(BaseModel):
|
|
| 36 |
name: str | None = None,
|
| 37 |
description: str | None = None,
|
| 38 |
mime_type: str | None = None,
|
|
|
|
| 39 |
) -> ResourceTemplate:
|
| 40 |
"""Create a template from a function."""
|
| 41 |
func_name = name or fn.__name__
|
|
@@ -55,6 +61,7 @@ class ResourceTemplate(BaseModel):
|
|
| 55 |
mime_type=mime_type or "text/plain",
|
| 56 |
fn=fn,
|
| 57 |
parameters=parameters,
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
def matches(self, uri: str) -> dict[str, Any] | None:
|
|
@@ -80,6 +87,19 @@ class ResourceTemplate(BaseModel):
|
|
| 80 |
description=self.description,
|
| 81 |
mime_type=self.mime_type,
|
| 82 |
fn=lambda: result, # Capture result in closure
|
|
|
|
| 83 |
)
|
| 84 |
except Exception as e:
|
| 85 |
raise ValueError(f"Error creating resource from template: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import inspect
|
| 6 |
import re
|
| 7 |
from collections.abc import Callable
|
| 8 |
+
from typing import Annotated, Any
|
| 9 |
|
| 10 |
+
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
| 11 |
+
from typing_extensions import Self
|
| 12 |
|
| 13 |
from fastmcp.resources.types import FunctionResource, Resource
|
| 14 |
+
from fastmcp.utilities.types import _convert_set_defaults
|
| 15 |
|
| 16 |
|
| 17 |
class ResourceTemplate(BaseModel):
|
|
|
|
| 22 |
)
|
| 23 |
name: str = Field(description="Name of the resource")
|
| 24 |
description: str | None = Field(description="Description of what the resource does")
|
| 25 |
+
tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
|
| 26 |
+
default_factory=set, description="Tags for the resource"
|
| 27 |
+
)
|
| 28 |
mime_type: str = Field(
|
| 29 |
default="text/plain", description="MIME type of the resource content"
|
| 30 |
)
|
| 31 |
+
fn: Callable[..., Any]
|
| 32 |
parameters: dict[str, Any] = Field(
|
| 33 |
description="JSON schema for function parameters"
|
| 34 |
)
|
|
|
|
| 41 |
name: str | None = None,
|
| 42 |
description: str | None = None,
|
| 43 |
mime_type: str | None = None,
|
| 44 |
+
tags: set[str] | None = None,
|
| 45 |
) -> ResourceTemplate:
|
| 46 |
"""Create a template from a function."""
|
| 47 |
func_name = name or fn.__name__
|
|
|
|
| 61 |
mime_type=mime_type or "text/plain",
|
| 62 |
fn=fn,
|
| 63 |
parameters=parameters,
|
| 64 |
+
tags=tags or set(),
|
| 65 |
)
|
| 66 |
|
| 67 |
def matches(self, uri: str) -> dict[str, Any] | None:
|
|
|
|
| 87 |
description=self.description,
|
| 88 |
mime_type=self.mime_type,
|
| 89 |
fn=lambda: result, # Capture result in closure
|
| 90 |
+
tags=self.tags,
|
| 91 |
)
|
| 92 |
except Exception as e:
|
| 93 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 94 |
+
|
| 95 |
+
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 96 |
+
"""Copy the resource template with optional updates."""
|
| 97 |
+
data = self.model_dump()
|
| 98 |
+
if updates:
|
| 99 |
+
data.update(updates)
|
| 100 |
+
return type(self)(**data)
|
| 101 |
+
|
| 102 |
+
def __eq__(self, other: object) -> bool:
|
| 103 |
+
if not isinstance(other, ResourceTemplate):
|
| 104 |
+
return False
|
| 105 |
+
return self.model_dump() == other.model_dump()
|
src/fastmcp/resources/types.py
CHANGED
|
@@ -13,7 +13,7 @@ import pydantic.json
|
|
| 13 |
import pydantic_core
|
| 14 |
from pydantic import Field, ValidationInfo
|
| 15 |
|
| 16 |
-
from fastmcp.resources.
|
| 17 |
|
| 18 |
|
| 19 |
class TextResource(Resource):
|
|
@@ -49,7 +49,7 @@ class FunctionResource(Resource):
|
|
| 49 |
- other types will be converted to JSON
|
| 50 |
"""
|
| 51 |
|
| 52 |
-
fn: Callable[[], Any]
|
| 53 |
|
| 54 |
async def read(self) -> str | bytes:
|
| 55 |
"""Read the resource by calling the wrapped function."""
|
|
|
|
| 13 |
import pydantic_core
|
| 14 |
from pydantic import Field, ValidationInfo
|
| 15 |
|
| 16 |
+
from fastmcp.resources.resource import Resource
|
| 17 |
|
| 18 |
|
| 19 |
class TextResource(Resource):
|
|
|
|
| 49 |
- other types will be converted to JSON
|
| 50 |
"""
|
| 51 |
|
| 52 |
+
fn: Callable[[], Any]
|
| 53 |
|
| 54 |
async def read(self) -> str | bytes:
|
| 55 |
"""Read the resource by calling the wrapped function."""
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -12,7 +12,7 @@ from pydantic.networks import AnyUrl
|
|
| 12 |
|
| 13 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 14 |
from fastmcp.server.server import FastMCP
|
| 15 |
-
from fastmcp.tools.
|
| 16 |
from fastmcp.utilities import openapi
|
| 17 |
from fastmcp.utilities.func_metadata import func_metadata
|
| 18 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 12 |
|
| 13 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 14 |
from fastmcp.server.server import FastMCP
|
| 15 |
+
from fastmcp.tools.tool import Tool
|
| 16 |
from fastmcp.utilities import openapi
|
| 17 |
from fastmcp.utilities.func_metadata import func_metadata
|
| 18 |
from fastmcp.utilities.logging import get_logger
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -9,7 +9,7 @@ from fastmcp.prompts import Prompt
|
|
| 9 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 10 |
from fastmcp.server.context import Context
|
| 11 |
from fastmcp.server.server import FastMCP
|
| 12 |
-
from fastmcp.tools.
|
| 13 |
from fastmcp.utilities.func_metadata import func_metadata
|
| 14 |
from fastmcp.utilities.logging import get_logger
|
| 15 |
|
|
|
|
| 9 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 10 |
from fastmcp.server.context import Context
|
| 11 |
from fastmcp.server.server import FastMCP
|
| 12 |
+
from fastmcp.tools.tool import Tool
|
| 13 |
from fastmcp.utilities.func_metadata import func_metadata
|
| 14 |
from fastmcp.utilities.logging import get_logger
|
| 15 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -88,13 +88,13 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 88 |
lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
|
| 89 |
)
|
| 90 |
self._tool_manager = ToolManager(
|
| 91 |
-
|
| 92 |
)
|
| 93 |
self._resource_manager = ResourceManager(
|
| 94 |
-
|
| 95 |
)
|
| 96 |
self._prompt_manager = PromptManager(
|
| 97 |
-
|
| 98 |
)
|
| 99 |
self.dependencies = self.settings.dependencies
|
| 100 |
|
|
@@ -228,6 +228,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 228 |
fn: AnyFunction,
|
| 229 |
name: str | None = None,
|
| 230 |
description: str | None = None,
|
|
|
|
| 231 |
) -> None:
|
| 232 |
"""Add a tool to the server.
|
| 233 |
|
|
@@ -238,11 +239,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 238 |
fn: The function to register as a tool
|
| 239 |
name: Optional name for the tool (defaults to function name)
|
| 240 |
description: Optional description of what the tool does
|
|
|
|
| 241 |
"""
|
| 242 |
-
self._tool_manager.
|
|
|
|
|
|
|
| 243 |
|
| 244 |
def tool(
|
| 245 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 246 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 247 |
"""Decorator to register a tool.
|
| 248 |
|
|
@@ -253,6 +260,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 253 |
Args:
|
| 254 |
name: Optional name for the tool (defaults to function name)
|
| 255 |
description: Optional description of what the tool does
|
|
|
|
| 256 |
|
| 257 |
Example:
|
| 258 |
@server.tool()
|
|
@@ -277,7 +285,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 277 |
)
|
| 278 |
|
| 279 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 280 |
-
self.add_tool(fn, name=name, description=description)
|
| 281 |
return fn
|
| 282 |
|
| 283 |
return decorator
|
|
@@ -297,6 +305,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 297 |
name: str | None = None,
|
| 298 |
description: str | None = None,
|
| 299 |
mime_type: str | None = None,
|
|
|
|
| 300 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 301 |
"""Decorator to register a function as a resource.
|
| 302 |
|
|
@@ -314,6 +323,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 314 |
name: Optional name for the resource
|
| 315 |
description: Optional description of the resource
|
| 316 |
mime_type: Optional MIME type for the resource
|
|
|
|
| 317 |
|
| 318 |
Example:
|
| 319 |
@server.resource("resource://my-resource")
|
|
@@ -358,12 +368,13 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 358 |
)
|
| 359 |
|
| 360 |
# Register as template
|
| 361 |
-
self._resource_manager.
|
| 362 |
fn=fn,
|
| 363 |
uri_template=uri,
|
| 364 |
name=name,
|
| 365 |
description=description,
|
| 366 |
mime_type=mime_type or "text/plain",
|
|
|
|
| 367 |
)
|
| 368 |
else:
|
| 369 |
# Register as regular resource
|
|
@@ -373,6 +384,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 373 |
description=description,
|
| 374 |
mime_type=mime_type or "text/plain",
|
| 375 |
fn=fn,
|
|
|
|
| 376 |
)
|
| 377 |
self.add_resource(resource)
|
| 378 |
return fn
|
|
@@ -388,13 +400,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 388 |
self._prompt_manager.add_prompt(prompt)
|
| 389 |
|
| 390 |
def prompt(
|
| 391 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 392 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 393 |
"""Decorator to register a prompt.
|
| 394 |
|
| 395 |
Args:
|
| 396 |
name: Optional name for the prompt (defaults to function name)
|
| 397 |
description: Optional description of what the prompt does
|
|
|
|
| 398 |
|
| 399 |
Example:
|
| 400 |
@server.prompt()
|
|
@@ -431,7 +447,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 431 |
)
|
| 432 |
|
| 433 |
def decorator(func: AnyFunction) -> AnyFunction:
|
| 434 |
-
prompt = Prompt.from_function(
|
|
|
|
|
|
|
| 435 |
self.add_prompt(prompt)
|
| 436 |
return func
|
| 437 |
|
|
|
|
| 88 |
lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
|
| 89 |
)
|
| 90 |
self._tool_manager = ToolManager(
|
| 91 |
+
duplicate_behavior=self.settings.on_duplicate_tools
|
| 92 |
)
|
| 93 |
self._resource_manager = ResourceManager(
|
| 94 |
+
duplicate_behavior=self.settings.on_duplicate_resources
|
| 95 |
)
|
| 96 |
self._prompt_manager = PromptManager(
|
| 97 |
+
duplicate_behavior=self.settings.on_duplicate_prompts
|
| 98 |
)
|
| 99 |
self.dependencies = self.settings.dependencies
|
| 100 |
|
|
|
|
| 228 |
fn: AnyFunction,
|
| 229 |
name: str | None = None,
|
| 230 |
description: str | None = None,
|
| 231 |
+
tags: set[str] | None = None,
|
| 232 |
) -> None:
|
| 233 |
"""Add a tool to the server.
|
| 234 |
|
|
|
|
| 239 |
fn: The function to register as a tool
|
| 240 |
name: Optional name for the tool (defaults to function name)
|
| 241 |
description: Optional description of what the tool does
|
| 242 |
+
tags: Optional set of tags for categorizing the tool
|
| 243 |
"""
|
| 244 |
+
self._tool_manager.add_tool_from_fn(
|
| 245 |
+
fn, name=name, description=description, tags=tags
|
| 246 |
+
)
|
| 247 |
|
| 248 |
def tool(
|
| 249 |
+
self,
|
| 250 |
+
name: str | None = None,
|
| 251 |
+
description: str | None = None,
|
| 252 |
+
tags: set[str] | None = None,
|
| 253 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 254 |
"""Decorator to register a tool.
|
| 255 |
|
|
|
|
| 260 |
Args:
|
| 261 |
name: Optional name for the tool (defaults to function name)
|
| 262 |
description: Optional description of what the tool does
|
| 263 |
+
tags: Optional set of tags for categorizing the tool
|
| 264 |
|
| 265 |
Example:
|
| 266 |
@server.tool()
|
|
|
|
| 285 |
)
|
| 286 |
|
| 287 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 288 |
+
self.add_tool(fn, name=name, description=description, tags=tags)
|
| 289 |
return fn
|
| 290 |
|
| 291 |
return decorator
|
|
|
|
| 305 |
name: str | None = None,
|
| 306 |
description: str | None = None,
|
| 307 |
mime_type: str | None = None,
|
| 308 |
+
tags: set[str] | None = None,
|
| 309 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 310 |
"""Decorator to register a function as a resource.
|
| 311 |
|
|
|
|
| 323 |
name: Optional name for the resource
|
| 324 |
description: Optional description of the resource
|
| 325 |
mime_type: Optional MIME type for the resource
|
| 326 |
+
tags: Optional set of tags for categorizing the resource
|
| 327 |
|
| 328 |
Example:
|
| 329 |
@server.resource("resource://my-resource")
|
|
|
|
| 368 |
)
|
| 369 |
|
| 370 |
# Register as template
|
| 371 |
+
self._resource_manager.add_template_from_fn(
|
| 372 |
fn=fn,
|
| 373 |
uri_template=uri,
|
| 374 |
name=name,
|
| 375 |
description=description,
|
| 376 |
mime_type=mime_type or "text/plain",
|
| 377 |
+
tags=tags,
|
| 378 |
)
|
| 379 |
else:
|
| 380 |
# Register as regular resource
|
|
|
|
| 384 |
description=description,
|
| 385 |
mime_type=mime_type or "text/plain",
|
| 386 |
fn=fn,
|
| 387 |
+
tags=tags or set(), # Default to empty set if None
|
| 388 |
)
|
| 389 |
self.add_resource(resource)
|
| 390 |
return fn
|
|
|
|
| 400 |
self._prompt_manager.add_prompt(prompt)
|
| 401 |
|
| 402 |
def prompt(
|
| 403 |
+
self,
|
| 404 |
+
name: str | None = None,
|
| 405 |
+
description: str | None = None,
|
| 406 |
+
tags: set[str] | None = None,
|
| 407 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 408 |
"""Decorator to register a prompt.
|
| 409 |
|
| 410 |
Args:
|
| 411 |
name: Optional name for the prompt (defaults to function name)
|
| 412 |
description: Optional description of what the prompt does
|
| 413 |
+
tags: Optional set of tags for categorizing the prompt
|
| 414 |
|
| 415 |
Example:
|
| 416 |
@server.prompt()
|
|
|
|
| 447 |
)
|
| 448 |
|
| 449 |
def decorator(func: AnyFunction) -> AnyFunction:
|
| 450 |
+
prompt = Prompt.from_function(
|
| 451 |
+
func, name=name, description=description, tags=tags
|
| 452 |
+
)
|
| 453 |
self.add_prompt(prompt)
|
| 454 |
return func
|
| 455 |
|
src/fastmcp/settings.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations as _annotations
|
| 2 |
|
|
|
|
| 3 |
from typing import TYPE_CHECKING, Literal
|
| 4 |
|
| 5 |
from pydantic import Field
|
|
@@ -11,6 +12,13 @@ if TYPE_CHECKING:
|
|
| 11 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
class Settings(BaseSettings):
|
| 15 |
"""FastMCP settings."""
|
| 16 |
|
|
@@ -47,13 +55,13 @@ class ServerSettings(BaseSettings):
|
|
| 47 |
debug: bool = False
|
| 48 |
|
| 49 |
# resource settings
|
| 50 |
-
|
| 51 |
|
| 52 |
# tool settings
|
| 53 |
-
|
| 54 |
|
| 55 |
# prompt settings
|
| 56 |
-
|
| 57 |
|
| 58 |
dependencies: list[str] = Field(
|
| 59 |
default_factory=list,
|
|
|
|
| 1 |
from __future__ import annotations as _annotations
|
| 2 |
|
| 3 |
+
from enum import Enum
|
| 4 |
from typing import TYPE_CHECKING, Literal
|
| 5 |
|
| 6 |
from pydantic import Field
|
|
|
|
| 12 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
| 13 |
|
| 14 |
|
| 15 |
+
class DuplicateBehavior(Enum):
|
| 16 |
+
WARN = "warn"
|
| 17 |
+
ERROR = "error"
|
| 18 |
+
REPLACE = "replace"
|
| 19 |
+
IGNORE = "ignore"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
class Settings(BaseSettings):
|
| 23 |
"""FastMCP settings."""
|
| 24 |
|
|
|
|
| 55 |
debug: bool = False
|
| 56 |
|
| 57 |
# resource settings
|
| 58 |
+
on_duplicate_resources: DuplicateBehavior = DuplicateBehavior.WARN
|
| 59 |
|
| 60 |
# tool settings
|
| 61 |
+
on_duplicate_tools: DuplicateBehavior = DuplicateBehavior.WARN
|
| 62 |
|
| 63 |
# prompt settings
|
| 64 |
+
on_duplicate_prompts: DuplicateBehavior = DuplicateBehavior.WARN
|
| 65 |
|
| 66 |
dependencies: list[str] = Field(
|
| 67 |
default_factory=list,
|
src/fastmcp/tools/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from .
|
| 2 |
from .tool_manager import ToolManager
|
| 3 |
|
| 4 |
__all__ = ["Tool", "ToolManager"]
|
|
|
|
| 1 |
+
from .tool import Tool
|
| 2 |
from .tool_manager import ToolManager
|
| 3 |
|
| 4 |
__all__ = ["Tool", "ToolManager"]
|
src/fastmcp/tools/{base.py → tool.py}
RENAMED
|
@@ -2,12 +2,14 @@ from __future__ import annotations as _annotations
|
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
from collections.abc import Callable
|
| 5 |
-
from typing import TYPE_CHECKING, Any
|
| 6 |
|
| 7 |
-
from pydantic import BaseModel, Field
|
|
|
|
| 8 |
|
| 9 |
from fastmcp.exceptions import ToolError
|
| 10 |
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
|
|
|
| 11 |
|
| 12 |
if TYPE_CHECKING:
|
| 13 |
from mcp.server.session import ServerSessionT
|
|
@@ -19,7 +21,7 @@ if TYPE_CHECKING:
|
|
| 19 |
class Tool(BaseModel):
|
| 20 |
"""Internal tool registration info."""
|
| 21 |
|
| 22 |
-
fn: Callable[..., Any]
|
| 23 |
name: str = Field(description="Name of the tool")
|
| 24 |
description: str = Field(description="Description of what the tool does")
|
| 25 |
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
|
@@ -31,6 +33,9 @@ class Tool(BaseModel):
|
|
| 31 |
context_kwarg: str | None = Field(
|
| 32 |
None, description="Name of the kwarg that should receive context"
|
| 33 |
)
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
@classmethod
|
| 36 |
def from_function(
|
|
@@ -39,6 +44,7 @@ class Tool(BaseModel):
|
|
| 39 |
name: str | None = None,
|
| 40 |
description: str | None = None,
|
| 41 |
context_kwarg: str | None = None,
|
|
|
|
| 42 |
) -> Tool:
|
| 43 |
"""Create a Tool from a function."""
|
| 44 |
from fastmcp import Context
|
|
@@ -72,6 +78,7 @@ class Tool(BaseModel):
|
|
| 72 |
fn_metadata=func_arg_metadata,
|
| 73 |
is_async=is_async,
|
| 74 |
context_kwarg=context_kwarg,
|
|
|
|
| 75 |
)
|
| 76 |
|
| 77 |
async def run(
|
|
@@ -91,3 +98,15 @@ class Tool(BaseModel):
|
|
| 91 |
)
|
| 92 |
except Exception as e:
|
| 93 |
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
from collections.abc import Callable
|
| 5 |
+
from typing import TYPE_CHECKING, Annotated, Any
|
| 6 |
|
| 7 |
+
from pydantic import BaseModel, BeforeValidator, Field
|
| 8 |
+
from typing_extensions import Self
|
| 9 |
|
| 10 |
from fastmcp.exceptions import ToolError
|
| 11 |
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
| 12 |
+
from fastmcp.utilities.types import _convert_set_defaults
|
| 13 |
|
| 14 |
if TYPE_CHECKING:
|
| 15 |
from mcp.server.session import ServerSessionT
|
|
|
|
| 21 |
class Tool(BaseModel):
|
| 22 |
"""Internal tool registration info."""
|
| 23 |
|
| 24 |
+
fn: Callable[..., Any]
|
| 25 |
name: str = Field(description="Name of the tool")
|
| 26 |
description: str = Field(description="Description of what the tool does")
|
| 27 |
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
|
|
|
| 33 |
context_kwarg: str | None = Field(
|
| 34 |
None, description="Name of the kwarg that should receive context"
|
| 35 |
)
|
| 36 |
+
tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
|
| 37 |
+
default_factory=set, description="Tags for the tool"
|
| 38 |
+
)
|
| 39 |
|
| 40 |
@classmethod
|
| 41 |
def from_function(
|
|
|
|
| 44 |
name: str | None = None,
|
| 45 |
description: str | None = None,
|
| 46 |
context_kwarg: str | None = None,
|
| 47 |
+
tags: set[str] | None = None,
|
| 48 |
) -> Tool:
|
| 49 |
"""Create a Tool from a function."""
|
| 50 |
from fastmcp import Context
|
|
|
|
| 78 |
fn_metadata=func_arg_metadata,
|
| 79 |
is_async=is_async,
|
| 80 |
context_kwarg=context_kwarg,
|
| 81 |
+
tags=tags or set(),
|
| 82 |
)
|
| 83 |
|
| 84 |
async def run(
|
|
|
|
| 98 |
)
|
| 99 |
except Exception as e:
|
| 100 |
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
| 101 |
+
|
| 102 |
+
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 103 |
+
"""Copy the tool with optional updates."""
|
| 104 |
+
data = self.model_dump()
|
| 105 |
+
if updates:
|
| 106 |
+
data.update(updates)
|
| 107 |
+
return type(self)(**data)
|
| 108 |
+
|
| 109 |
+
def __eq__(self, other: object) -> bool:
|
| 110 |
+
if not isinstance(other, Tool):
|
| 111 |
+
return False
|
| 112 |
+
return self.model_dump() == other.model_dump()
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, Any
|
|
| 6 |
from mcp.shared.context import LifespanContextT
|
| 7 |
|
| 8 |
from fastmcp.exceptions import ToolError
|
| 9 |
-
from fastmcp.
|
|
|
|
| 10 |
from fastmcp.utilities.logging import get_logger
|
| 11 |
|
| 12 |
if TYPE_CHECKING:
|
|
@@ -20,9 +21,9 @@ logger = get_logger(__name__)
|
|
| 20 |
class ToolManager:
|
| 21 |
"""Manages FastMCP tools."""
|
| 22 |
|
| 23 |
-
def __init__(self,
|
| 24 |
self._tools: dict[str, Tool] = {}
|
| 25 |
-
self.
|
| 26 |
|
| 27 |
def get_tool(self, name: str) -> Tool | None:
|
| 28 |
"""Get tool by name."""
|
|
@@ -32,19 +33,30 @@ class ToolManager:
|
|
| 32 |
"""List all registered tools."""
|
| 33 |
return list(self._tools.values())
|
| 34 |
|
| 35 |
-
def
|
| 36 |
self,
|
| 37 |
fn: Callable[..., Any],
|
| 38 |
name: str | None = None,
|
| 39 |
description: str | None = None,
|
|
|
|
| 40 |
) -> Tool:
|
| 41 |
"""Add a tool to the server."""
|
| 42 |
-
tool = Tool.from_function(fn, name=name, description=description)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
existing = self._tools.get(tool.name)
|
| 44 |
if existing:
|
| 45 |
-
if self.
|
| 46 |
logger.warning(f"Tool already exists: {tool.name}")
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
self._tools[tool.name] = tool
|
| 49 |
return tool
|
| 50 |
|
|
@@ -78,13 +90,7 @@ class ToolManager:
|
|
| 78 |
for name, tool in tool_manager._tools.items():
|
| 79 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 80 |
|
| 81 |
-
|
| 82 |
-
copied_tool = Tool.from_function(
|
| 83 |
-
tool.fn,
|
| 84 |
-
name=prefixed_name,
|
| 85 |
-
description=tool.description,
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
# Store the copied tool
|
| 89 |
-
self.
|
| 90 |
logger.debug(f"Imported tool: {name} as {prefixed_name}")
|
|
|
|
| 6 |
from mcp.shared.context import LifespanContextT
|
| 7 |
|
| 8 |
from fastmcp.exceptions import ToolError
|
| 9 |
+
from fastmcp.settings import DuplicateBehavior
|
| 10 |
+
from fastmcp.tools.tool import Tool
|
| 11 |
from fastmcp.utilities.logging import get_logger
|
| 12 |
|
| 13 |
if TYPE_CHECKING:
|
|
|
|
| 21 |
class ToolManager:
|
| 22 |
"""Manages FastMCP tools."""
|
| 23 |
|
| 24 |
+
def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
|
| 25 |
self._tools: dict[str, Tool] = {}
|
| 26 |
+
self.duplicate_behavior = duplicate_behavior
|
| 27 |
|
| 28 |
def get_tool(self, name: str) -> Tool | None:
|
| 29 |
"""Get tool by name."""
|
|
|
|
| 33 |
"""List all registered tools."""
|
| 34 |
return list(self._tools.values())
|
| 35 |
|
| 36 |
+
def add_tool_from_fn(
|
| 37 |
self,
|
| 38 |
fn: Callable[..., Any],
|
| 39 |
name: str | None = None,
|
| 40 |
description: str | None = None,
|
| 41 |
+
tags: set[str] | None = None,
|
| 42 |
) -> Tool:
|
| 43 |
"""Add a tool to the server."""
|
| 44 |
+
tool = Tool.from_function(fn, name=name, description=description, tags=tags)
|
| 45 |
+
return self.add_tool(tool)
|
| 46 |
+
|
| 47 |
+
def add_tool(self, tool: Tool) -> Tool:
|
| 48 |
+
"""Register a tool with the server."""
|
| 49 |
existing = self._tools.get(tool.name)
|
| 50 |
if existing:
|
| 51 |
+
if self.duplicate_behavior == DuplicateBehavior.WARN:
|
| 52 |
logger.warning(f"Tool already exists: {tool.name}")
|
| 53 |
+
self._tools[tool.name] = tool
|
| 54 |
+
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
|
| 55 |
+
self._tools[tool.name] = tool
|
| 56 |
+
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
|
| 57 |
+
raise ValueError(f"Tool already exists: {tool.name}")
|
| 58 |
+
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
|
| 59 |
+
pass
|
| 60 |
self._tools[tool.name] = tool
|
| 61 |
return tool
|
| 62 |
|
|
|
|
| 90 |
for name, tool in tool_manager._tools.items():
|
| 91 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 92 |
|
| 93 |
+
new_tool = tool.copy(updates=dict(name=prefixed_name))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# Store the copied tool
|
| 95 |
+
self.add_tool(new_tool)
|
| 96 |
logger.debug(f"Imported tool: {name} as {prefixed_name}")
|
src/fastmcp/utilities/types.py
CHANGED
|
@@ -2,9 +2,21 @@
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
from pathlib import Path
|
|
|
|
| 5 |
|
| 6 |
from mcp.types import ImageContent
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
class Image:
|
| 10 |
"""Helper class for returning images from tools."""
|
|
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
from pathlib import Path
|
| 5 |
+
from typing import TypeVar
|
| 6 |
|
| 7 |
from mcp.types import ImageContent
|
| 8 |
|
| 9 |
+
T = TypeVar("T")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _convert_set_defaults(maybe_set: set[T] | list[T] | None) -> set[T]:
|
| 13 |
+
"""Convert a set or list to a set, defaulting to an empty set if None."""
|
| 14 |
+
if maybe_set is None:
|
| 15 |
+
return set()
|
| 16 |
+
if isinstance(maybe_set, set):
|
| 17 |
+
return maybe_set
|
| 18 |
+
return set(maybe_set)
|
| 19 |
+
|
| 20 |
|
| 21 |
class Image:
|
| 22 |
"""Helper class for returning images from tools."""
|
tests/prompts/test_base.py
CHANGED
|
@@ -2,7 +2,7 @@ import pytest
|
|
| 2 |
from mcp.types import EmbeddedResource, TextResourceContents
|
| 3 |
from pydantic import FileUrl
|
| 4 |
|
| 5 |
-
from fastmcp.prompts.
|
| 6 |
AssistantMessage,
|
| 7 |
Message,
|
| 8 |
Prompt,
|
|
|
|
| 2 |
from mcp.types import EmbeddedResource, TextResourceContents
|
| 3 |
from pydantic import FileUrl
|
| 4 |
|
| 5 |
+
from fastmcp.prompts.prompt import (
|
| 6 |
AssistantMessage,
|
| 7 |
Message,
|
| 8 |
Prompt,
|
tests/prompts/test_prompt_manager.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
from fastmcp.prompts import Prompt
|
| 4 |
-
from fastmcp.prompts.
|
| 5 |
from fastmcp.prompts.prompt_manager import PromptManager
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class TestPromptManager:
|
|
@@ -24,7 +25,7 @@ class TestPromptManager:
|
|
| 24 |
def fn() -> str:
|
| 25 |
return "Hello, world!"
|
| 26 |
|
| 27 |
-
manager = PromptManager()
|
| 28 |
prompt = Prompt.from_function(fn)
|
| 29 |
first = manager.add_prompt(prompt)
|
| 30 |
second = manager.add_prompt(prompt)
|
|
@@ -37,7 +38,7 @@ class TestPromptManager:
|
|
| 37 |
def fn() -> str:
|
| 38 |
return "Hello, world!"
|
| 39 |
|
| 40 |
-
manager = PromptManager(
|
| 41 |
prompt = Prompt.from_function(fn)
|
| 42 |
first = manager.add_prompt(prompt)
|
| 43 |
second = manager.add_prompt(prompt)
|
|
@@ -112,6 +113,131 @@ class TestPromptManager:
|
|
| 112 |
with pytest.raises(ValueError, match="Missing required arguments"):
|
| 113 |
await manager.render_prompt("fn")
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
class TestImports:
|
| 117 |
def test_import_prompts(self):
|
|
@@ -119,27 +245,13 @@ class TestImports:
|
|
| 119 |
# Setup source manager with prompts
|
| 120 |
source_manager = PromptManager()
|
| 121 |
|
| 122 |
-
# Create test prompts with proper function handlers
|
| 123 |
-
async def summary_fn(**kwargs):
|
| 124 |
-
return [
|
| 125 |
-
{"role": "assistant", "content": f"Summary of: {kwargs.get('text')}"}
|
| 126 |
-
]
|
| 127 |
-
|
| 128 |
-
async def translate_fn(**kwargs):
|
| 129 |
-
return [
|
| 130 |
-
{
|
| 131 |
-
"role": "assistant",
|
| 132 |
-
"content": f"Translation to {kwargs.get('language')}: {kwargs.get('text')}",
|
| 133 |
-
}
|
| 134 |
-
]
|
| 135 |
-
|
| 136 |
summary_prompt = Prompt(
|
| 137 |
name="summary",
|
| 138 |
description="Generate a summary of text",
|
| 139 |
arguments=[PromptArgument(name="text", description="Text to summarize")],
|
| 140 |
-
fn=
|
| 141 |
)
|
| 142 |
-
source_manager.
|
| 143 |
|
| 144 |
translate_prompt = Prompt(
|
| 145 |
name="translate",
|
|
@@ -148,9 +260,9 @@ class TestImports:
|
|
| 148 |
PromptArgument(name="text", description="Text to translate"),
|
| 149 |
PromptArgument(name="language", description="Target language"),
|
| 150 |
],
|
| 151 |
-
fn=
|
| 152 |
)
|
| 153 |
-
source_manager.
|
| 154 |
|
| 155 |
# Create target manager
|
| 156 |
target_manager = PromptManager()
|
|
@@ -167,31 +279,8 @@ class TestImports:
|
|
| 167 |
assert "summary" in source_manager._prompts
|
| 168 |
assert "translate" in source_manager._prompts
|
| 169 |
|
| 170 |
-
|
| 171 |
-
assert target_manager._prompts["nlp/
|
| 172 |
-
assert (
|
| 173 |
-
target_manager._prompts["nlp/summary"].description
|
| 174 |
-
== "Generate a summary of text"
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
assert target_manager._prompts["nlp/translate"].name == "translate"
|
| 178 |
-
assert (
|
| 179 |
-
target_manager._prompts["nlp/translate"].description
|
| 180 |
-
== "Translate text to another language"
|
| 181 |
-
)
|
| 182 |
-
|
| 183 |
-
# Verify functions were properly copied
|
| 184 |
-
if hasattr(target_manager._prompts["nlp/summary"], "fn"):
|
| 185 |
-
assert (
|
| 186 |
-
target_manager._prompts["nlp/summary"].fn.__name__
|
| 187 |
-
== summary_fn.__name__
|
| 188 |
-
)
|
| 189 |
-
|
| 190 |
-
if hasattr(target_manager._prompts["nlp/translate"], "fn"):
|
| 191 |
-
assert (
|
| 192 |
-
target_manager._prompts["nlp/translate"].fn.__name__
|
| 193 |
-
== translate_fn.__name__
|
| 194 |
-
)
|
| 195 |
|
| 196 |
def test_import_prompts_with_duplicates(self):
|
| 197 |
"""Test handling of duplicate prompts during import."""
|
|
@@ -199,18 +288,11 @@ class TestImports:
|
|
| 199 |
source_manager = PromptManager()
|
| 200 |
target_manager = PromptManager()
|
| 201 |
|
| 202 |
-
# Add the same prompt name to both managers with functions
|
| 203 |
-
async def source_fn(**kwargs):
|
| 204 |
-
return [{"role": "assistant", "content": "Source content"}]
|
| 205 |
-
|
| 206 |
-
async def target_fn(**kwargs):
|
| 207 |
-
return [{"role": "assistant", "content": "Target content"}]
|
| 208 |
-
|
| 209 |
source_prompt = Prompt(
|
| 210 |
name="common",
|
| 211 |
description="Source description",
|
| 212 |
arguments=None,
|
| 213 |
-
fn=
|
| 214 |
)
|
| 215 |
source_manager._prompts["common"] = source_prompt
|
| 216 |
|
|
@@ -218,7 +300,7 @@ class TestImports:
|
|
| 218 |
name="common",
|
| 219 |
description="Target description",
|
| 220 |
arguments=None,
|
| 221 |
-
fn=
|
| 222 |
)
|
| 223 |
target_manager._prompts["common"] = target_prompt
|
| 224 |
|
|
@@ -230,15 +312,8 @@ class TestImports:
|
|
| 230 |
assert "common" in target_manager._prompts
|
| 231 |
assert "external/common" in target_manager._prompts
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
target_manager._prompts["external/common"], "fn"
|
| 236 |
-
):
|
| 237 |
-
assert target_manager._prompts["common"].fn.__name__ == target_fn.__name__
|
| 238 |
-
assert (
|
| 239 |
-
target_manager._prompts["external/common"].fn.__name__
|
| 240 |
-
== source_fn.__name__
|
| 241 |
-
)
|
| 242 |
|
| 243 |
def test_import_prompts_with_nested_prefixes(self):
|
| 244 |
"""Test importing already prefixed prompts."""
|
|
@@ -247,17 +322,11 @@ class TestImports:
|
|
| 247 |
second_manager = PromptManager()
|
| 248 |
third_manager = PromptManager()
|
| 249 |
|
| 250 |
-
# Add prompt to first manager with a function
|
| 251 |
-
async def analyze_fn(**kwargs):
|
| 252 |
-
return [
|
| 253 |
-
{"role": "assistant", "content": f"Analysis of: {kwargs.get('text')}"}
|
| 254 |
-
]
|
| 255 |
-
|
| 256 |
original_prompt = Prompt(
|
| 257 |
name="analyze",
|
| 258 |
description="Analyze text",
|
| 259 |
arguments=[PromptArgument(name="text", description="Text to analyze")],
|
| 260 |
-
fn=
|
| 261 |
)
|
| 262 |
first_manager._prompts["analyze"] = original_prompt
|
| 263 |
|
|
@@ -271,13 +340,4 @@ class TestImports:
|
|
| 271 |
assert "text/analyze" in second_manager._prompts
|
| 272 |
assert "ai/text/analyze" in third_manager._prompts
|
| 273 |
|
| 274 |
-
|
| 275 |
-
assert third_manager._prompts["ai/text/analyze"].name == "analyze"
|
| 276 |
-
assert third_manager._prompts["ai/text/analyze"].description == "Analyze text"
|
| 277 |
-
|
| 278 |
-
# Verify function was properly copied through multiple imports
|
| 279 |
-
if hasattr(third_manager._prompts["ai/text/analyze"], "fn"):
|
| 280 |
-
assert (
|
| 281 |
-
third_manager._prompts["ai/text/analyze"].fn.__name__
|
| 282 |
-
== analyze_fn.__name__
|
| 283 |
-
)
|
|
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
from fastmcp.prompts import Prompt
|
| 4 |
+
from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
|
| 5 |
from fastmcp.prompts.prompt_manager import PromptManager
|
| 6 |
+
from fastmcp.settings import DuplicateBehavior
|
| 7 |
|
| 8 |
|
| 9 |
class TestPromptManager:
|
|
|
|
| 25 |
def fn() -> str:
|
| 26 |
return "Hello, world!"
|
| 27 |
|
| 28 |
+
manager = PromptManager(duplicate_behavior=DuplicateBehavior.WARN)
|
| 29 |
prompt = Prompt.from_function(fn)
|
| 30 |
first = manager.add_prompt(prompt)
|
| 31 |
second = manager.add_prompt(prompt)
|
|
|
|
| 38 |
def fn() -> str:
|
| 39 |
return "Hello, world!"
|
| 40 |
|
| 41 |
+
manager = PromptManager(duplicate_behavior=DuplicateBehavior.IGNORE)
|
| 42 |
prompt = Prompt.from_function(fn)
|
| 43 |
first = manager.add_prompt(prompt)
|
| 44 |
second = manager.add_prompt(prompt)
|
|
|
|
| 113 |
with pytest.raises(ValueError, match="Missing required arguments"):
|
| 114 |
await manager.render_prompt("fn")
|
| 115 |
|
| 116 |
+
def test_error_on_duplicate_prompts(self):
|
| 117 |
+
"""Test error on duplicate prompts."""
|
| 118 |
+
|
| 119 |
+
def fn() -> str:
|
| 120 |
+
return "Hello, world!"
|
| 121 |
+
|
| 122 |
+
manager = PromptManager(duplicate_behavior=DuplicateBehavior.ERROR)
|
| 123 |
+
prompt = Prompt.from_function(fn)
|
| 124 |
+
manager.add_prompt(prompt)
|
| 125 |
+
|
| 126 |
+
with pytest.raises(ValueError, match="Prompt already exists"):
|
| 127 |
+
manager.add_prompt(prompt)
|
| 128 |
+
|
| 129 |
+
def test_replace_duplicate_prompts(self):
|
| 130 |
+
"""Test replacing duplicate prompts."""
|
| 131 |
+
|
| 132 |
+
def fn1() -> str:
|
| 133 |
+
return "Original"
|
| 134 |
+
|
| 135 |
+
def fn2() -> str:
|
| 136 |
+
return "Replacement"
|
| 137 |
+
|
| 138 |
+
manager = PromptManager(duplicate_behavior=DuplicateBehavior.REPLACE)
|
| 139 |
+
prompt1 = Prompt.from_function(fn1, name="test_prompt")
|
| 140 |
+
prompt2 = Prompt.from_function(fn2, name="test_prompt")
|
| 141 |
+
|
| 142 |
+
manager.add_prompt(prompt1)
|
| 143 |
+
manager.add_prompt(prompt2)
|
| 144 |
+
|
| 145 |
+
# Should have replaced the first prompt with the second
|
| 146 |
+
stored_prompt = manager.get_prompt("test_prompt")
|
| 147 |
+
assert stored_prompt == prompt2
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TestPromptTags:
|
| 151 |
+
"""Test functionality related to prompt tags."""
|
| 152 |
+
|
| 153 |
+
def test_add_prompt_with_tags(self):
|
| 154 |
+
"""Test adding a prompt with tags."""
|
| 155 |
+
|
| 156 |
+
def greeting() -> str:
|
| 157 |
+
return "Hello, world!"
|
| 158 |
+
|
| 159 |
+
manager = PromptManager()
|
| 160 |
+
prompt = Prompt.from_function(greeting, tags={"greeting", "simple"})
|
| 161 |
+
manager.add_prompt(prompt)
|
| 162 |
+
|
| 163 |
+
prompt = manager.get_prompt("greeting")
|
| 164 |
+
assert prompt is not None
|
| 165 |
+
assert prompt.tags == {"greeting", "simple"}
|
| 166 |
+
|
| 167 |
+
def test_add_prompt_with_empty_tags(self):
|
| 168 |
+
"""Test adding a prompt with empty tags."""
|
| 169 |
+
|
| 170 |
+
def greeting() -> str:
|
| 171 |
+
return "Hello, world!"
|
| 172 |
+
|
| 173 |
+
manager = PromptManager()
|
| 174 |
+
prompt = Prompt.from_function(greeting, tags=set())
|
| 175 |
+
manager.add_prompt(prompt)
|
| 176 |
+
|
| 177 |
+
prompt = manager.get_prompt("greeting")
|
| 178 |
+
assert prompt is not None
|
| 179 |
+
assert prompt.tags == set()
|
| 180 |
+
|
| 181 |
+
def test_add_prompt_with_none_tags(self):
|
| 182 |
+
"""Test adding a prompt with None tags."""
|
| 183 |
+
|
| 184 |
+
def greeting() -> str:
|
| 185 |
+
return "Hello, world!"
|
| 186 |
+
|
| 187 |
+
manager = PromptManager()
|
| 188 |
+
prompt = Prompt.from_function(greeting, tags=None)
|
| 189 |
+
manager.add_prompt(prompt)
|
| 190 |
+
|
| 191 |
+
prompt = manager.get_prompt("greeting")
|
| 192 |
+
assert prompt is not None
|
| 193 |
+
assert prompt.tags == set()
|
| 194 |
+
|
| 195 |
+
def test_list_prompts_with_tags(self):
|
| 196 |
+
"""Test listing prompts with specific tags."""
|
| 197 |
+
|
| 198 |
+
def greeting() -> str:
|
| 199 |
+
return "Hello, world!"
|
| 200 |
+
|
| 201 |
+
def weather(location: str) -> str:
|
| 202 |
+
return f"Weather for {location}"
|
| 203 |
+
|
| 204 |
+
def summary(text: str) -> str:
|
| 205 |
+
return f"Summary of: {text}"
|
| 206 |
+
|
| 207 |
+
manager = PromptManager()
|
| 208 |
+
manager.add_prompt(Prompt.from_function(greeting, tags={"greeting", "simple"}))
|
| 209 |
+
manager.add_prompt(Prompt.from_function(weather, tags={"weather", "location"}))
|
| 210 |
+
manager.add_prompt(
|
| 211 |
+
Prompt.from_function(summary, tags={"summary", "nlp", "simple"})
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Filter prompts by tags
|
| 215 |
+
simple_prompts = [p for p in manager.list_prompts() if "simple" in p.tags]
|
| 216 |
+
assert len(simple_prompts) == 2
|
| 217 |
+
assert {p.name for p in simple_prompts} == {"greeting", "summary"}
|
| 218 |
+
|
| 219 |
+
nlp_prompts = [p for p in manager.list_prompts() if "nlp" in p.tags]
|
| 220 |
+
assert len(nlp_prompts) == 1
|
| 221 |
+
assert nlp_prompts[0].name == "summary"
|
| 222 |
+
|
| 223 |
+
def test_import_prompts_preserves_tags(self):
|
| 224 |
+
"""Test that importing prompts preserves their tags."""
|
| 225 |
+
source_manager = PromptManager()
|
| 226 |
+
|
| 227 |
+
def sample_prompt() -> str:
|
| 228 |
+
return "Sample prompt"
|
| 229 |
+
|
| 230 |
+
source_manager.add_prompt(
|
| 231 |
+
Prompt.from_function(sample_prompt, tags={"example", "test"})
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
target_manager = PromptManager()
|
| 235 |
+
target_manager.import_prompts(source_manager, "imported/")
|
| 236 |
+
|
| 237 |
+
imported_prompt = target_manager.get_prompt("imported/sample_prompt")
|
| 238 |
+
assert imported_prompt is not None
|
| 239 |
+
assert imported_prompt.tags == {"example", "test"}
|
| 240 |
+
|
| 241 |
|
| 242 |
class TestImports:
|
| 243 |
def test_import_prompts(self):
|
|
|
|
| 245 |
# Setup source manager with prompts
|
| 246 |
source_manager = PromptManager()
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
summary_prompt = Prompt(
|
| 249 |
name="summary",
|
| 250 |
description="Generate a summary of text",
|
| 251 |
arguments=[PromptArgument(name="text", description="Text to summarize")],
|
| 252 |
+
fn=lambda: None, # type: ignore
|
| 253 |
)
|
| 254 |
+
source_manager.add_prompt(summary_prompt)
|
| 255 |
|
| 256 |
translate_prompt = Prompt(
|
| 257 |
name="translate",
|
|
|
|
| 260 |
PromptArgument(name="text", description="Text to translate"),
|
| 261 |
PromptArgument(name="language", description="Target language"),
|
| 262 |
],
|
| 263 |
+
fn=lambda: None, # type: ignore
|
| 264 |
)
|
| 265 |
+
source_manager.add_prompt(translate_prompt)
|
| 266 |
|
| 267 |
# Create target manager
|
| 268 |
target_manager = PromptManager()
|
|
|
|
| 279 |
assert "summary" in source_manager._prompts
|
| 280 |
assert "translate" in source_manager._prompts
|
| 281 |
|
| 282 |
+
assert target_manager._prompts["nlp/summary"].fn == summary_prompt.fn
|
| 283 |
+
assert target_manager._prompts["nlp/translate"].fn == translate_prompt.fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
def test_import_prompts_with_duplicates(self):
|
| 286 |
"""Test handling of duplicate prompts during import."""
|
|
|
|
| 288 |
source_manager = PromptManager()
|
| 289 |
target_manager = PromptManager()
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
source_prompt = Prompt(
|
| 292 |
name="common",
|
| 293 |
description="Source description",
|
| 294 |
arguments=None,
|
| 295 |
+
fn=lambda: None, # type: ignore
|
| 296 |
)
|
| 297 |
source_manager._prompts["common"] = source_prompt
|
| 298 |
|
|
|
|
| 300 |
name="common",
|
| 301 |
description="Target description",
|
| 302 |
arguments=None,
|
| 303 |
+
fn=lambda: None, # type: ignore
|
| 304 |
)
|
| 305 |
target_manager._prompts["common"] = target_prompt
|
| 306 |
|
|
|
|
| 312 |
assert "common" in target_manager._prompts
|
| 313 |
assert "external/common" in target_manager._prompts
|
| 314 |
|
| 315 |
+
assert target_manager._prompts["common"].fn == target_prompt.fn
|
| 316 |
+
assert target_manager._prompts["external/common"].fn == source_prompt.fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
def test_import_prompts_with_nested_prefixes(self):
|
| 319 |
"""Test importing already prefixed prompts."""
|
|
|
|
| 322 |
second_manager = PromptManager()
|
| 323 |
third_manager = PromptManager()
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
original_prompt = Prompt(
|
| 326 |
name="analyze",
|
| 327 |
description="Analyze text",
|
| 328 |
arguments=[PromptArgument(name="text", description="Text to analyze")],
|
| 329 |
+
fn=lambda: None, # type: ignore
|
| 330 |
)
|
| 331 |
first_manager._prompts["analyze"] = original_prompt
|
| 332 |
|
|
|
|
| 340 |
assert "text/analyze" in second_manager._prompts
|
| 341 |
assert "ai/text/analyze" in third_manager._prompts
|
| 342 |
|
| 343 |
+
assert third_manager._prompts["ai/text/analyze"].fn == original_prompt.fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/resources/test_resource_manager.py
CHANGED
|
@@ -10,6 +10,7 @@ from fastmcp.resources import (
|
|
| 10 |
ResourceManager,
|
| 11 |
ResourceTemplate,
|
| 12 |
)
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
@pytest.fixture
|
|
@@ -59,7 +60,7 @@ class TestResourceManager:
|
|
| 59 |
|
| 60 |
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 61 |
"""Test warning on duplicate resources."""
|
| 62 |
-
manager = ResourceManager()
|
| 63 |
resource = FileResource(
|
| 64 |
uri=FileUrl(f"file://{temp_file}"),
|
| 65 |
name="test",
|
|
@@ -71,7 +72,7 @@ class TestResourceManager:
|
|
| 71 |
|
| 72 |
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 73 |
"""Test disabling warning on duplicate resources."""
|
| 74 |
-
manager = ResourceManager(
|
| 75 |
resource = FileResource(
|
| 76 |
uri=FileUrl(f"file://{temp_file}"),
|
| 77 |
name="test",
|
|
@@ -81,6 +82,43 @@ class TestResourceManager:
|
|
| 81 |
manager.add_resource(resource)
|
| 82 |
assert "Resource already exists" not in caplog.text
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
@pytest.mark.anyio
|
| 85 |
async def test_get_resource(self, temp_file: Path):
|
| 86 |
"""Test getting a resource by URI."""
|
|
@@ -141,6 +179,162 @@ class TestResourceManager:
|
|
| 141 |
assert resources == [resource1, resource2]
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
class TestImports:
|
| 145 |
def test_import_resources(self):
|
| 146 |
"""Test importing resources from one manager to another with a prefix."""
|
|
|
|
| 10 |
ResourceManager,
|
| 11 |
ResourceTemplate,
|
| 12 |
)
|
| 13 |
+
from fastmcp.settings import DuplicateBehavior
|
| 14 |
|
| 15 |
|
| 16 |
@pytest.fixture
|
|
|
|
| 60 |
|
| 61 |
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 62 |
"""Test warning on duplicate resources."""
|
| 63 |
+
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.WARN)
|
| 64 |
resource = FileResource(
|
| 65 |
uri=FileUrl(f"file://{temp_file}"),
|
| 66 |
name="test",
|
|
|
|
| 72 |
|
| 73 |
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 74 |
"""Test disabling warning on duplicate resources."""
|
| 75 |
+
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.IGNORE)
|
| 76 |
resource = FileResource(
|
| 77 |
uri=FileUrl(f"file://{temp_file}"),
|
| 78 |
name="test",
|
|
|
|
| 82 |
manager.add_resource(resource)
|
| 83 |
assert "Resource already exists" not in caplog.text
|
| 84 |
|
| 85 |
+
def test_error_on_duplicate_resources(self, temp_file: Path):
|
| 86 |
+
"""Test error on duplicate resources."""
|
| 87 |
+
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.ERROR)
|
| 88 |
+
resource = FileResource(
|
| 89 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 90 |
+
name="test",
|
| 91 |
+
path=temp_file,
|
| 92 |
+
)
|
| 93 |
+
manager.add_resource(resource)
|
| 94 |
+
|
| 95 |
+
with pytest.raises(ValueError, match="Resource already exists"):
|
| 96 |
+
manager.add_resource(resource)
|
| 97 |
+
|
| 98 |
+
def test_replace_duplicate_resources(self, temp_file: Path):
|
| 99 |
+
"""Test replacing duplicate resources."""
|
| 100 |
+
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.REPLACE)
|
| 101 |
+
|
| 102 |
+
resource1 = FileResource(
|
| 103 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 104 |
+
name="test1",
|
| 105 |
+
path=temp_file,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
resource2 = FileResource(
|
| 109 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 110 |
+
name="test2", # Different name
|
| 111 |
+
path=temp_file,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
manager.add_resource(resource1)
|
| 115 |
+
manager.add_resource(resource2)
|
| 116 |
+
|
| 117 |
+
# Should have replaced the first resource with the second
|
| 118 |
+
resources = manager.list_resources()
|
| 119 |
+
assert len(resources) == 1
|
| 120 |
+
assert resources[0].name == "test2"
|
| 121 |
+
|
| 122 |
@pytest.mark.anyio
|
| 123 |
async def test_get_resource(self, temp_file: Path):
|
| 124 |
"""Test getting a resource by URI."""
|
|
|
|
| 179 |
assert resources == [resource1, resource2]
|
| 180 |
|
| 181 |
|
| 182 |
+
class TestResourceTags:
|
| 183 |
+
"""Test functionality related to resource tags."""
|
| 184 |
+
|
| 185 |
+
def test_add_resource_with_tags(self, temp_file: Path):
|
| 186 |
+
"""Test adding a resource with tags."""
|
| 187 |
+
manager = ResourceManager()
|
| 188 |
+
resource = FileResource(
|
| 189 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 190 |
+
name="weather_data",
|
| 191 |
+
path=temp_file,
|
| 192 |
+
tags={"weather", "data"},
|
| 193 |
+
)
|
| 194 |
+
manager.add_resource(resource)
|
| 195 |
+
|
| 196 |
+
# Check that tags are preserved
|
| 197 |
+
resources = manager.list_resources()
|
| 198 |
+
assert len(resources) == 1
|
| 199 |
+
assert resources[0].tags == {"weather", "data"}
|
| 200 |
+
|
| 201 |
+
def test_add_function_resource_with_tags(self):
|
| 202 |
+
"""Test adding a function resource with tags."""
|
| 203 |
+
manager = ResourceManager()
|
| 204 |
+
|
| 205 |
+
async def get_data():
|
| 206 |
+
return "Sample data"
|
| 207 |
+
|
| 208 |
+
resource = FunctionResource(
|
| 209 |
+
uri=AnyUrl("data://sample"),
|
| 210 |
+
name="sample_data",
|
| 211 |
+
description="Sample data resource",
|
| 212 |
+
mime_type="text/plain",
|
| 213 |
+
fn=get_data,
|
| 214 |
+
tags={"sample", "test", "data"},
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
manager.add_resource(resource)
|
| 218 |
+
resources = manager.list_resources()
|
| 219 |
+
assert len(resources) == 1
|
| 220 |
+
assert resources[0].tags == {"sample", "test", "data"}
|
| 221 |
+
|
| 222 |
+
def test_add_template_with_tags(self):
|
| 223 |
+
"""Test adding a resource template with tags."""
|
| 224 |
+
manager = ResourceManager()
|
| 225 |
+
|
| 226 |
+
def user_data(user_id: str) -> str:
|
| 227 |
+
return f"Data for user {user_id}"
|
| 228 |
+
|
| 229 |
+
template = ResourceTemplate.from_function(
|
| 230 |
+
fn=user_data,
|
| 231 |
+
uri_template="users://{user_id}",
|
| 232 |
+
name="user_template",
|
| 233 |
+
description="Get user data by ID",
|
| 234 |
+
tags={"users", "template", "data"},
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
manager.add_template(template)
|
| 238 |
+
templates = manager.list_templates()
|
| 239 |
+
assert len(templates) == 1
|
| 240 |
+
assert templates[0].tags == {"users", "template", "data"}
|
| 241 |
+
|
| 242 |
+
def test_filter_resources_by_tags(self, temp_file: Path):
|
| 243 |
+
"""Test filtering resources by tags."""
|
| 244 |
+
manager = ResourceManager()
|
| 245 |
+
|
| 246 |
+
# Create multiple resources with different tags
|
| 247 |
+
resource1 = FileResource(
|
| 248 |
+
uri=FileUrl(f"file://{temp_file}1"),
|
| 249 |
+
name="weather_data",
|
| 250 |
+
path=temp_file,
|
| 251 |
+
tags={"weather", "external"},
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
async def get_user_data():
|
| 255 |
+
return "User data"
|
| 256 |
+
|
| 257 |
+
resource2 = FunctionResource(
|
| 258 |
+
uri=AnyUrl("data://users"),
|
| 259 |
+
name="user_data",
|
| 260 |
+
fn=get_user_data,
|
| 261 |
+
tags={"users", "internal"},
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
async def get_system_data():
|
| 265 |
+
return "System data"
|
| 266 |
+
|
| 267 |
+
resource3 = FunctionResource(
|
| 268 |
+
uri=AnyUrl("data://system"),
|
| 269 |
+
name="system_data",
|
| 270 |
+
fn=get_system_data,
|
| 271 |
+
tags={"system", "internal"},
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
manager.add_resource(resource1)
|
| 275 |
+
manager.add_resource(resource2)
|
| 276 |
+
manager.add_resource(resource3)
|
| 277 |
+
|
| 278 |
+
# Filter resources by tags
|
| 279 |
+
internal_resources = [
|
| 280 |
+
r for r in manager.list_resources() if "internal" in r.tags
|
| 281 |
+
]
|
| 282 |
+
assert len(internal_resources) == 2
|
| 283 |
+
assert {r.name for r in internal_resources} == {"user_data", "system_data"}
|
| 284 |
+
|
| 285 |
+
external_resources = [
|
| 286 |
+
r for r in manager.list_resources() if "external" in r.tags
|
| 287 |
+
]
|
| 288 |
+
assert len(external_resources) == 1
|
| 289 |
+
assert external_resources[0].name == "weather_data"
|
| 290 |
+
|
| 291 |
+
def test_import_resources_preserves_tags(self):
|
| 292 |
+
"""Test that importing resources preserves their tags."""
|
| 293 |
+
source_manager = ResourceManager()
|
| 294 |
+
|
| 295 |
+
async def get_data():
|
| 296 |
+
return "Tagged data"
|
| 297 |
+
|
| 298 |
+
resource = FunctionResource(
|
| 299 |
+
uri=AnyUrl("data://tagged"),
|
| 300 |
+
name="tagged_data",
|
| 301 |
+
fn=get_data,
|
| 302 |
+
tags={"test", "example", "data"},
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
source_manager.add_resource(resource)
|
| 306 |
+
|
| 307 |
+
target_manager = ResourceManager()
|
| 308 |
+
target_manager.import_resources(source_manager, "imported+")
|
| 309 |
+
|
| 310 |
+
imported_resources = target_manager.list_resources()
|
| 311 |
+
assert len(imported_resources) == 1
|
| 312 |
+
assert imported_resources[0].tags == {"test", "example", "data"}
|
| 313 |
+
|
| 314 |
+
def test_import_templates_preserves_tags(self):
|
| 315 |
+
"""Test that importing templates preserves their tags."""
|
| 316 |
+
source_manager = ResourceManager()
|
| 317 |
+
|
| 318 |
+
def user_template(user_id: str) -> str:
|
| 319 |
+
return f"User {user_id}"
|
| 320 |
+
|
| 321 |
+
template = ResourceTemplate.from_function(
|
| 322 |
+
fn=user_template,
|
| 323 |
+
uri_template="users://{user_id}",
|
| 324 |
+
name="user_template",
|
| 325 |
+
tags={"users", "template", "test"},
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
source_manager.add_template(template)
|
| 329 |
+
|
| 330 |
+
target_manager = ResourceManager()
|
| 331 |
+
target_manager.import_templates(source_manager, "imported+")
|
| 332 |
+
|
| 333 |
+
imported_templates = target_manager.list_templates()
|
| 334 |
+
assert len(imported_templates) == 1
|
| 335 |
+
assert imported_templates[0].tags == {"users", "template", "test"}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
class TestImports:
|
| 339 |
def test_import_resources(self):
|
| 340 |
"""Test importing resources from one manager to another with a prefix."""
|
tests/server/test_server.py
CHANGED
|
@@ -17,7 +17,7 @@ from mcp.types import (
|
|
| 17 |
from pydantic import AnyUrl, Field
|
| 18 |
|
| 19 |
from fastmcp import Context, FastMCP
|
| 20 |
-
from fastmcp.prompts.
|
| 21 |
from fastmcp.resources import FileResource, FunctionResource
|
| 22 |
from fastmcp.utilities.types import Image
|
| 23 |
|
|
@@ -482,7 +482,7 @@ class TestContextInjection:
|
|
| 482 |
def tool_with_context(x: int, ctx: Context) -> str:
|
| 483 |
return f"Request {ctx.request_id}: {x}"
|
| 484 |
|
| 485 |
-
tool = mcp._tool_manager.
|
| 486 |
assert tool.context_kwarg == "ctx"
|
| 487 |
|
| 488 |
async def test_context_injection(self):
|
|
|
|
| 17 |
from pydantic import AnyUrl, Field
|
| 18 |
|
| 19 |
from fastmcp import Context, FastMCP
|
| 20 |
+
from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
|
| 21 |
from fastmcp.resources import FileResource, FunctionResource
|
| 22 |
from fastmcp.utilities.types import Image
|
| 23 |
|
|
|
|
| 482 |
def tool_with_context(x: int, ctx: Context) -> str:
|
| 483 |
return f"Request {ctx.request_id}: {x}"
|
| 484 |
|
| 485 |
+
tool = mcp._tool_manager.add_tool_from_fn(tool_with_context)
|
| 486 |
assert tool.context_kwarg == "ctx"
|
| 487 |
|
| 488 |
async def test_context_injection(self):
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -5,6 +5,7 @@ import pytest
|
|
| 5 |
from pydantic import BaseModel
|
| 6 |
|
| 7 |
from fastmcp.exceptions import ToolError
|
|
|
|
| 8 |
from fastmcp.tools import ToolManager
|
| 9 |
|
| 10 |
|
|
@@ -17,7 +18,7 @@ class TestAddTools:
|
|
| 17 |
return a + b
|
| 18 |
|
| 19 |
manager = ToolManager()
|
| 20 |
-
manager.
|
| 21 |
|
| 22 |
tool = manager.get_tool("add")
|
| 23 |
assert tool is not None
|
|
@@ -36,7 +37,7 @@ class TestAddTools:
|
|
| 36 |
return f"Data from {url}"
|
| 37 |
|
| 38 |
manager = ToolManager()
|
| 39 |
-
manager.
|
| 40 |
|
| 41 |
tool = manager.get_tool("fetch_data")
|
| 42 |
assert tool is not None
|
|
@@ -57,7 +58,7 @@ class TestAddTools:
|
|
| 57 |
return {"id": 1, **user.model_dump()}
|
| 58 |
|
| 59 |
manager = ToolManager()
|
| 60 |
-
manager.
|
| 61 |
|
| 62 |
tool = manager.get_tool("create_user")
|
| 63 |
assert tool is not None
|
|
@@ -71,11 +72,11 @@ class TestAddTools:
|
|
| 71 |
def test_add_invalid_tool(self):
|
| 72 |
manager = ToolManager()
|
| 73 |
with pytest.raises(AttributeError):
|
| 74 |
-
manager.
|
| 75 |
|
| 76 |
def test_add_lambda(self):
|
| 77 |
manager = ToolManager()
|
| 78 |
-
tool = manager.
|
| 79 |
assert tool.name == "my_tool"
|
| 80 |
|
| 81 |
def test_add_lambda_with_no_name(self):
|
|
@@ -83,7 +84,7 @@ class TestAddTools:
|
|
| 83 |
with pytest.raises(
|
| 84 |
ValueError, match="You must provide a name for lambda functions"
|
| 85 |
):
|
| 86 |
-
manager.
|
| 87 |
|
| 88 |
def test_warn_on_duplicate_tools(self, caplog):
|
| 89 |
"""Test warning on duplicate tools."""
|
|
@@ -91,10 +92,10 @@ class TestAddTools:
|
|
| 91 |
def f(x: int) -> int:
|
| 92 |
return x
|
| 93 |
|
| 94 |
-
manager = ToolManager()
|
| 95 |
-
manager.
|
| 96 |
with caplog.at_level(logging.WARNING):
|
| 97 |
-
manager.
|
| 98 |
assert "Tool already exists: f" in caplog.text
|
| 99 |
|
| 100 |
def test_disable_warn_on_duplicate_tools(self, caplog):
|
|
@@ -103,13 +104,132 @@ class TestAddTools:
|
|
| 103 |
def f(x: int) -> int:
|
| 104 |
return x
|
| 105 |
|
| 106 |
-
manager = ToolManager()
|
| 107 |
-
manager.
|
| 108 |
-
manager.warn_on_duplicate_tools = False
|
| 109 |
with caplog.at_level(logging.WARNING):
|
| 110 |
-
manager.
|
| 111 |
assert "Tool already exists: f" not in caplog.text
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
class TestCallTools:
|
| 115 |
@pytest.mark.anyio
|
|
@@ -119,7 +239,7 @@ class TestCallTools:
|
|
| 119 |
return a + b
|
| 120 |
|
| 121 |
manager = ToolManager()
|
| 122 |
-
manager.
|
| 123 |
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
| 124 |
assert result == 3
|
| 125 |
|
|
@@ -130,7 +250,7 @@ class TestCallTools:
|
|
| 130 |
return n * 2
|
| 131 |
|
| 132 |
manager = ToolManager()
|
| 133 |
-
manager.
|
| 134 |
result = await manager.call_tool("double", {"n": 5})
|
| 135 |
assert result == 10
|
| 136 |
|
|
@@ -141,7 +261,7 @@ class TestCallTools:
|
|
| 141 |
return a + b
|
| 142 |
|
| 143 |
manager = ToolManager()
|
| 144 |
-
manager.
|
| 145 |
result = await manager.call_tool("add", {"a": 1})
|
| 146 |
assert result == 2
|
| 147 |
|
|
@@ -152,7 +272,7 @@ class TestCallTools:
|
|
| 152 |
return a + b
|
| 153 |
|
| 154 |
manager = ToolManager()
|
| 155 |
-
manager.
|
| 156 |
with pytest.raises(ToolError):
|
| 157 |
await manager.call_tool("add", {"a": 1})
|
| 158 |
|
|
@@ -168,7 +288,7 @@ class TestCallTools:
|
|
| 168 |
return sum(vals)
|
| 169 |
|
| 170 |
manager = ToolManager()
|
| 171 |
-
manager.
|
| 172 |
# Try both with plain list and with JSON list
|
| 173 |
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
| 174 |
assert result == 6
|
|
@@ -181,7 +301,7 @@ class TestCallTools:
|
|
| 181 |
return vals if isinstance(vals, str) else "".join(vals)
|
| 182 |
|
| 183 |
manager = ToolManager()
|
| 184 |
-
manager.
|
| 185 |
# Try both with plain python object and with JSON list
|
| 186 |
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
| 187 |
assert result == "abc"
|
|
@@ -207,7 +327,7 @@ class TestCallTools:
|
|
| 207 |
return [x.name for x in tank.shrimp]
|
| 208 |
|
| 209 |
manager = ToolManager()
|
| 210 |
-
manager.
|
| 211 |
result = await manager.call_tool(
|
| 212 |
"name_shrimp",
|
| 213 |
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
|
|
@@ -229,7 +349,7 @@ class TestToolSchema:
|
|
| 229 |
return a
|
| 230 |
|
| 231 |
manager = ToolManager()
|
| 232 |
-
tool = manager.
|
| 233 |
assert "ctx" not in json.dumps(tool.parameters)
|
| 234 |
assert "Context" not in json.dumps(tool.parameters)
|
| 235 |
assert "ctx" not in tool.fn_metadata.arg_model.model_fields
|
|
@@ -247,13 +367,13 @@ class TestContextHandling:
|
|
| 247 |
return str(x)
|
| 248 |
|
| 249 |
manager = ToolManager()
|
| 250 |
-
tool = manager.
|
| 251 |
assert tool.context_kwarg == "ctx"
|
| 252 |
|
| 253 |
def tool_without_context(x: int) -> str:
|
| 254 |
return str(x)
|
| 255 |
|
| 256 |
-
tool = manager.
|
| 257 |
assert tool.context_kwarg is None
|
| 258 |
|
| 259 |
@pytest.mark.anyio
|
|
@@ -266,7 +386,7 @@ class TestContextHandling:
|
|
| 266 |
return str(x)
|
| 267 |
|
| 268 |
manager = ToolManager()
|
| 269 |
-
manager.
|
| 270 |
|
| 271 |
mcp = FastMCP()
|
| 272 |
ctx = mcp.get_context()
|
|
@@ -283,7 +403,7 @@ class TestContextHandling:
|
|
| 283 |
return str(x)
|
| 284 |
|
| 285 |
manager = ToolManager()
|
| 286 |
-
manager.
|
| 287 |
|
| 288 |
mcp = FastMCP()
|
| 289 |
ctx = mcp.get_context()
|
|
@@ -299,7 +419,7 @@ class TestContextHandling:
|
|
| 299 |
return str(x)
|
| 300 |
|
| 301 |
manager = ToolManager()
|
| 302 |
-
manager.
|
| 303 |
# Should not raise an error when context is not provided
|
| 304 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 305 |
assert result == "42"
|
|
@@ -313,7 +433,7 @@ class TestContextHandling:
|
|
| 313 |
raise ValueError("Test error")
|
| 314 |
|
| 315 |
manager = ToolManager()
|
| 316 |
-
manager.
|
| 317 |
|
| 318 |
mcp = FastMCP()
|
| 319 |
ctx = mcp.get_context()
|
|
@@ -335,8 +455,10 @@ class TestImportTools:
|
|
| 335 |
return "Tool 2 result"
|
| 336 |
|
| 337 |
# Add tools to source manager
|
| 338 |
-
source_manager.
|
| 339 |
-
|
|
|
|
|
|
|
| 340 |
tool2_fn, name="process_data", description="Process the data"
|
| 341 |
)
|
| 342 |
|
|
@@ -364,11 +486,8 @@ class TestImportTools:
|
|
| 364 |
|
| 365 |
# Verify the tool functions were properly copied
|
| 366 |
# We can't directly compare functions, so we'll check their __name__ attribute
|
| 367 |
-
assert target_manager._tools["source/get_data"].fn
|
| 368 |
-
assert
|
| 369 |
-
target_manager._tools["source/process_data"].fn.__name__
|
| 370 |
-
== tool2_fn.__name__
|
| 371 |
-
)
|
| 372 |
|
| 373 |
def test_tool_duplicate_behavior(self):
|
| 374 |
"""Test the behavior when importing tools with duplicate names."""
|
|
@@ -383,8 +502,8 @@ class TestImportTools:
|
|
| 383 |
def target_fn():
|
| 384 |
return "Target result"
|
| 385 |
|
| 386 |
-
source_manager.
|
| 387 |
-
target_manager.
|
| 388 |
target_fn, name="source/common_tool"
|
| 389 |
) # Pre-create with the prefixed name
|
| 390 |
|
|
@@ -392,10 +511,7 @@ class TestImportTools:
|
|
| 392 |
target_manager.import_tools(source_manager, "source/")
|
| 393 |
|
| 394 |
# The original tool in the target manager is replaced by the imported one
|
| 395 |
-
assert
|
| 396 |
-
target_manager._tools["source/common_tool"].fn.__name__
|
| 397 |
-
== source_fn.__name__
|
| 398 |
-
)
|
| 399 |
|
| 400 |
def test_import_tools_with_multiple_prefixes(self):
|
| 401 |
"""Test importing tools from multiple managers with different prefixes."""
|
|
@@ -410,8 +526,8 @@ class TestImportTools:
|
|
| 410 |
def headlines_fn():
|
| 411 |
return "News headlines"
|
| 412 |
|
| 413 |
-
weather_manager.
|
| 414 |
-
news_manager.
|
| 415 |
|
| 416 |
# Create target manager and import from both sources
|
| 417 |
main_manager = ToolManager()
|
|
|
|
| 5 |
from pydantic import BaseModel
|
| 6 |
|
| 7 |
from fastmcp.exceptions import ToolError
|
| 8 |
+
from fastmcp.settings import DuplicateBehavior
|
| 9 |
from fastmcp.tools import ToolManager
|
| 10 |
|
| 11 |
|
|
|
|
| 18 |
return a + b
|
| 19 |
|
| 20 |
manager = ToolManager()
|
| 21 |
+
manager.add_tool_from_fn(add)
|
| 22 |
|
| 23 |
tool = manager.get_tool("add")
|
| 24 |
assert tool is not None
|
|
|
|
| 37 |
return f"Data from {url}"
|
| 38 |
|
| 39 |
manager = ToolManager()
|
| 40 |
+
manager.add_tool_from_fn(fetch_data)
|
| 41 |
|
| 42 |
tool = manager.get_tool("fetch_data")
|
| 43 |
assert tool is not None
|
|
|
|
| 58 |
return {"id": 1, **user.model_dump()}
|
| 59 |
|
| 60 |
manager = ToolManager()
|
| 61 |
+
manager.add_tool_from_fn(create_user)
|
| 62 |
|
| 63 |
tool = manager.get_tool("create_user")
|
| 64 |
assert tool is not None
|
|
|
|
| 72 |
def test_add_invalid_tool(self):
|
| 73 |
manager = ToolManager()
|
| 74 |
with pytest.raises(AttributeError):
|
| 75 |
+
manager.add_tool_from_fn(1) # type: ignore
|
| 76 |
|
| 77 |
def test_add_lambda(self):
|
| 78 |
manager = ToolManager()
|
| 79 |
+
tool = manager.add_tool_from_fn(lambda x: x, name="my_tool")
|
| 80 |
assert tool.name == "my_tool"
|
| 81 |
|
| 82 |
def test_add_lambda_with_no_name(self):
|
|
|
|
| 84 |
with pytest.raises(
|
| 85 |
ValueError, match="You must provide a name for lambda functions"
|
| 86 |
):
|
| 87 |
+
manager.add_tool_from_fn(lambda x: x)
|
| 88 |
|
| 89 |
def test_warn_on_duplicate_tools(self, caplog):
|
| 90 |
"""Test warning on duplicate tools."""
|
|
|
|
| 92 |
def f(x: int) -> int:
|
| 93 |
return x
|
| 94 |
|
| 95 |
+
manager = ToolManager(duplicate_behavior=DuplicateBehavior.WARN)
|
| 96 |
+
manager.add_tool_from_fn(f)
|
| 97 |
with caplog.at_level(logging.WARNING):
|
| 98 |
+
manager.add_tool_from_fn(f)
|
| 99 |
assert "Tool already exists: f" in caplog.text
|
| 100 |
|
| 101 |
def test_disable_warn_on_duplicate_tools(self, caplog):
|
|
|
|
| 104 |
def f(x: int) -> int:
|
| 105 |
return x
|
| 106 |
|
| 107 |
+
manager = ToolManager(duplicate_behavior=DuplicateBehavior.IGNORE)
|
| 108 |
+
manager.add_tool_from_fn(f)
|
|
|
|
| 109 |
with caplog.at_level(logging.WARNING):
|
| 110 |
+
manager.add_tool_from_fn(f)
|
| 111 |
assert "Tool already exists: f" not in caplog.text
|
| 112 |
|
| 113 |
+
def test_error_on_duplicate_tools(self):
|
| 114 |
+
"""Test error on duplicate tools."""
|
| 115 |
+
|
| 116 |
+
def f(x: int) -> int:
|
| 117 |
+
return x
|
| 118 |
+
|
| 119 |
+
manager = ToolManager(duplicate_behavior=DuplicateBehavior.ERROR)
|
| 120 |
+
manager.add_tool_from_fn(f)
|
| 121 |
+
|
| 122 |
+
with pytest.raises(ValueError, match="Tool already exists"):
|
| 123 |
+
manager.add_tool_from_fn(f)
|
| 124 |
+
|
| 125 |
+
def test_replace_duplicate_tools(self):
|
| 126 |
+
"""Test replacing duplicate tools."""
|
| 127 |
+
|
| 128 |
+
def original_fn(x: int) -> int:
|
| 129 |
+
return x
|
| 130 |
+
|
| 131 |
+
def replacement_fn(x: int) -> int:
|
| 132 |
+
return x * 2
|
| 133 |
+
|
| 134 |
+
manager = ToolManager(duplicate_behavior=DuplicateBehavior.REPLACE)
|
| 135 |
+
manager.add_tool_from_fn(original_fn, name="test_tool")
|
| 136 |
+
replacement_tool = manager.add_tool_from_fn(replacement_fn, name="test_tool")
|
| 137 |
+
|
| 138 |
+
# Should have replaced the first tool with the second
|
| 139 |
+
stored_tool = manager.get_tool("test_tool")
|
| 140 |
+
assert stored_tool == replacement_tool
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class TestToolTags:
|
| 144 |
+
"""Test functionality related to tool tags."""
|
| 145 |
+
|
| 146 |
+
def test_add_tool_with_tags(self):
|
| 147 |
+
"""Test adding tags to a tool."""
|
| 148 |
+
|
| 149 |
+
def example_tool(x: int) -> int:
|
| 150 |
+
"""An example tool with tags."""
|
| 151 |
+
return x * 2
|
| 152 |
+
|
| 153 |
+
manager = ToolManager()
|
| 154 |
+
tool = manager.add_tool_from_fn(example_tool, tags={"math", "utility"})
|
| 155 |
+
|
| 156 |
+
assert tool.tags == {"math", "utility"}
|
| 157 |
+
tool = manager.get_tool("example_tool")
|
| 158 |
+
assert tool is not None
|
| 159 |
+
assert tool.tags == {"math", "utility"}
|
| 160 |
+
|
| 161 |
+
def test_add_tool_with_empty_tags(self):
|
| 162 |
+
"""Test adding a tool with empty tags set."""
|
| 163 |
+
|
| 164 |
+
def example_tool(x: int) -> int:
|
| 165 |
+
"""An example tool with empty tags."""
|
| 166 |
+
return x * 2
|
| 167 |
+
|
| 168 |
+
manager = ToolManager()
|
| 169 |
+
tool = manager.add_tool_from_fn(example_tool, tags=set())
|
| 170 |
+
|
| 171 |
+
assert tool.tags == set()
|
| 172 |
+
|
| 173 |
+
def test_add_tool_with_none_tags(self):
|
| 174 |
+
"""Test adding a tool with None tags."""
|
| 175 |
+
|
| 176 |
+
def example_tool(x: int) -> int:
|
| 177 |
+
"""An example tool with None tags."""
|
| 178 |
+
return x * 2
|
| 179 |
+
|
| 180 |
+
manager = ToolManager()
|
| 181 |
+
tool = manager.add_tool_from_fn(example_tool, tags=None)
|
| 182 |
+
|
| 183 |
+
assert tool.tags == set()
|
| 184 |
+
|
| 185 |
+
def test_list_tools_with_tags(self):
|
| 186 |
+
"""Test listing tools with specific tags."""
|
| 187 |
+
|
| 188 |
+
def math_tool(x: int) -> int:
|
| 189 |
+
"""A math tool."""
|
| 190 |
+
return x * 2
|
| 191 |
+
|
| 192 |
+
def string_tool(x: str) -> str:
|
| 193 |
+
"""A string tool."""
|
| 194 |
+
return x.upper()
|
| 195 |
+
|
| 196 |
+
def mixed_tool(x: int) -> str:
|
| 197 |
+
"""A tool with multiple tags."""
|
| 198 |
+
return str(x)
|
| 199 |
+
|
| 200 |
+
manager = ToolManager()
|
| 201 |
+
manager.add_tool_from_fn(math_tool, tags={"math"})
|
| 202 |
+
manager.add_tool_from_fn(string_tool, tags={"string", "utility"})
|
| 203 |
+
manager.add_tool_from_fn(mixed_tool, tags={"math", "utility", "string"})
|
| 204 |
+
|
| 205 |
+
# Check if we can filter by tags when listing tools
|
| 206 |
+
math_tools = [tool for tool in manager.list_tools() if "math" in tool.tags]
|
| 207 |
+
assert len(math_tools) == 2
|
| 208 |
+
assert {tool.name for tool in math_tools} == {"math_tool", "mixed_tool"}
|
| 209 |
+
|
| 210 |
+
utility_tools = [
|
| 211 |
+
tool for tool in manager.list_tools() if "utility" in tool.tags
|
| 212 |
+
]
|
| 213 |
+
assert len(utility_tools) == 2
|
| 214 |
+
assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
|
| 215 |
+
|
| 216 |
+
def test_import_tools_preserves_tags(self):
|
| 217 |
+
"""Test that importing tools preserves their tags."""
|
| 218 |
+
|
| 219 |
+
def tagged_tool(x: int) -> int:
|
| 220 |
+
"""A tool with tags."""
|
| 221 |
+
return x
|
| 222 |
+
|
| 223 |
+
source_manager = ToolManager()
|
| 224 |
+
source_manager.add_tool_from_fn(tagged_tool, tags={"test", "example"})
|
| 225 |
+
|
| 226 |
+
target_manager = ToolManager()
|
| 227 |
+
target_manager.import_tools(source_manager, "source/")
|
| 228 |
+
|
| 229 |
+
imported_tool = target_manager.get_tool("source/tagged_tool")
|
| 230 |
+
assert imported_tool is not None
|
| 231 |
+
assert imported_tool.tags == {"test", "example"}
|
| 232 |
+
|
| 233 |
|
| 234 |
class TestCallTools:
|
| 235 |
@pytest.mark.anyio
|
|
|
|
| 239 |
return a + b
|
| 240 |
|
| 241 |
manager = ToolManager()
|
| 242 |
+
manager.add_tool_from_fn(add)
|
| 243 |
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
| 244 |
assert result == 3
|
| 245 |
|
|
|
|
| 250 |
return n * 2
|
| 251 |
|
| 252 |
manager = ToolManager()
|
| 253 |
+
manager.add_tool_from_fn(double)
|
| 254 |
result = await manager.call_tool("double", {"n": 5})
|
| 255 |
assert result == 10
|
| 256 |
|
|
|
|
| 261 |
return a + b
|
| 262 |
|
| 263 |
manager = ToolManager()
|
| 264 |
+
manager.add_tool_from_fn(add)
|
| 265 |
result = await manager.call_tool("add", {"a": 1})
|
| 266 |
assert result == 2
|
| 267 |
|
|
|
|
| 272 |
return a + b
|
| 273 |
|
| 274 |
manager = ToolManager()
|
| 275 |
+
manager.add_tool_from_fn(add)
|
| 276 |
with pytest.raises(ToolError):
|
| 277 |
await manager.call_tool("add", {"a": 1})
|
| 278 |
|
|
|
|
| 288 |
return sum(vals)
|
| 289 |
|
| 290 |
manager = ToolManager()
|
| 291 |
+
manager.add_tool_from_fn(sum_vals)
|
| 292 |
# Try both with plain list and with JSON list
|
| 293 |
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
| 294 |
assert result == 6
|
|
|
|
| 301 |
return vals if isinstance(vals, str) else "".join(vals)
|
| 302 |
|
| 303 |
manager = ToolManager()
|
| 304 |
+
manager.add_tool_from_fn(concat_strs)
|
| 305 |
# Try both with plain python object and with JSON list
|
| 306 |
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
| 307 |
assert result == "abc"
|
|
|
|
| 327 |
return [x.name for x in tank.shrimp]
|
| 328 |
|
| 329 |
manager = ToolManager()
|
| 330 |
+
manager.add_tool_from_fn(name_shrimp)
|
| 331 |
result = await manager.call_tool(
|
| 332 |
"name_shrimp",
|
| 333 |
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
|
|
|
|
| 349 |
return a
|
| 350 |
|
| 351 |
manager = ToolManager()
|
| 352 |
+
tool = manager.add_tool_from_fn(something)
|
| 353 |
assert "ctx" not in json.dumps(tool.parameters)
|
| 354 |
assert "Context" not in json.dumps(tool.parameters)
|
| 355 |
assert "ctx" not in tool.fn_metadata.arg_model.model_fields
|
|
|
|
| 367 |
return str(x)
|
| 368 |
|
| 369 |
manager = ToolManager()
|
| 370 |
+
tool = manager.add_tool_from_fn(tool_with_context)
|
| 371 |
assert tool.context_kwarg == "ctx"
|
| 372 |
|
| 373 |
def tool_without_context(x: int) -> str:
|
| 374 |
return str(x)
|
| 375 |
|
| 376 |
+
tool = manager.add_tool_from_fn(tool_without_context)
|
| 377 |
assert tool.context_kwarg is None
|
| 378 |
|
| 379 |
@pytest.mark.anyio
|
|
|
|
| 386 |
return str(x)
|
| 387 |
|
| 388 |
manager = ToolManager()
|
| 389 |
+
manager.add_tool_from_fn(tool_with_context)
|
| 390 |
|
| 391 |
mcp = FastMCP()
|
| 392 |
ctx = mcp.get_context()
|
|
|
|
| 403 |
return str(x)
|
| 404 |
|
| 405 |
manager = ToolManager()
|
| 406 |
+
manager.add_tool_from_fn(async_tool)
|
| 407 |
|
| 408 |
mcp = FastMCP()
|
| 409 |
ctx = mcp.get_context()
|
|
|
|
| 419 |
return str(x)
|
| 420 |
|
| 421 |
manager = ToolManager()
|
| 422 |
+
manager.add_tool_from_fn(tool_with_context)
|
| 423 |
# Should not raise an error when context is not provided
|
| 424 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 425 |
assert result == "42"
|
|
|
|
| 433 |
raise ValueError("Test error")
|
| 434 |
|
| 435 |
manager = ToolManager()
|
| 436 |
+
manager.add_tool_from_fn(tool_with_context)
|
| 437 |
|
| 438 |
mcp = FastMCP()
|
| 439 |
ctx = mcp.get_context()
|
|
|
|
| 455 |
return "Tool 2 result"
|
| 456 |
|
| 457 |
# Add tools to source manager
|
| 458 |
+
source_manager.add_tool_from_fn(
|
| 459 |
+
tool1_fn, name="get_data", description="Get some data"
|
| 460 |
+
)
|
| 461 |
+
source_manager.add_tool_from_fn(
|
| 462 |
tool2_fn, name="process_data", description="Process the data"
|
| 463 |
)
|
| 464 |
|
|
|
|
| 486 |
|
| 487 |
# Verify the tool functions were properly copied
|
| 488 |
# We can't directly compare functions, so we'll check their __name__ attribute
|
| 489 |
+
assert target_manager._tools["source/get_data"].fn == tool1_fn
|
| 490 |
+
assert target_manager._tools["source/process_data"].fn == tool2_fn
|
|
|
|
|
|
|
|
|
|
| 491 |
|
| 492 |
def test_tool_duplicate_behavior(self):
|
| 493 |
"""Test the behavior when importing tools with duplicate names."""
|
|
|
|
| 502 |
def target_fn():
|
| 503 |
return "Target result"
|
| 504 |
|
| 505 |
+
source_manager.add_tool_from_fn(source_fn, name="common_tool")
|
| 506 |
+
target_manager.add_tool_from_fn(
|
| 507 |
target_fn, name="source/common_tool"
|
| 508 |
) # Pre-create with the prefixed name
|
| 509 |
|
|
|
|
| 511 |
target_manager.import_tools(source_manager, "source/")
|
| 512 |
|
| 513 |
# The original tool in the target manager is replaced by the imported one
|
| 514 |
+
assert target_manager._tools["source/common_tool"].fn == source_fn
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
def test_import_tools_with_multiple_prefixes(self):
|
| 517 |
"""Test importing tools from multiple managers with different prefixes."""
|
|
|
|
| 526 |
def headlines_fn():
|
| 527 |
return "News headlines"
|
| 528 |
|
| 529 |
+
weather_manager.add_tool_from_fn(forecast_fn, name="forecast")
|
| 530 |
+
news_manager.add_tool_from_fn(headlines_fn, name="headlines")
|
| 531 |
|
| 532 |
# Create target manager and import from both sources
|
| 533 |
main_manager = ToolManager()
|