Spaces:
Running
Running
Merge pull request #107 from jlowin/restore
Browse files- src/fastmcp/__init__.py +5 -6
- src/fastmcp/exceptions.py +21 -0
- src/fastmcp/prompts/__init__.py +2 -1
- src/fastmcp/prompts/base.py +166 -0
- src/fastmcp/prompts/prompt_manager.py +46 -8
- src/fastmcp/resources/__init__.py +21 -1
- src/fastmcp/resources/base.py +48 -0
- src/fastmcp/resources/resource_manager.py +91 -7
- src/fastmcp/resources/templates.py +85 -0
- src/fastmcp/resources/types.py +185 -0
- src/fastmcp/server/context.py +157 -6
- src/fastmcp/server/proxy.py +4 -4
- src/fastmcp/server/server.py +501 -17
- src/fastmcp/settings.py +54 -6
- src/fastmcp/tools/__init__.py +4 -0
- src/fastmcp/tools/base.py +93 -0
- src/fastmcp/tools/tool_manager.py +56 -7
- src/fastmcp/utilities/func_metadata.py +214 -0
- src/fastmcp/utilities/types.py +3 -4
- tests/prompts/__init__.py +0 -0
- tests/prompts/test_base.py +206 -0
- tests/prompts/test_prompt_manager.py +262 -145
- tests/resources/__init__.py +0 -0
- tests/resources/test_file_resources.py +119 -0
- tests/resources/test_function_resources.py +138 -0
- tests/resources/test_resource_manager.py +363 -221
- tests/resources/test_resource_template.py +188 -0
- tests/resources/test_resources.py +101 -0
- tests/server/__init__.py +0 -0
- tests/server/test_file_server.py +132 -0
- tests/server/test_lifespan.py +114 -0
- tests/server/test_server.py +770 -0
- tests/tools/test_tool_manager.py +405 -75
- tests/tools/tool_manager.py +0 -104
- tests/utilities/__init__.py +0 -0
- tests/utilities/test_func_metadata.py +416 -0
src/fastmcp/__init__.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
"""FastMCP - An ergonomic MCP interface."""
|
| 2 |
|
| 3 |
from importlib.metadata import version
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
from . import clients
|
| 6 |
|
| 7 |
__version__ = version("fastmcp")
|
| 8 |
-
__all__ = [
|
| 9 |
-
"FastMCP",
|
| 10 |
-
"Context",
|
| 11 |
-
"clients",
|
| 12 |
-
]
|
|
|
|
| 1 |
"""FastMCP - An ergonomic MCP interface."""
|
| 2 |
|
| 3 |
from importlib.metadata import version
|
| 4 |
+
import fastmcp.settings
|
| 5 |
+
|
| 6 |
+
from fastmcp.server.server import FastMCP
|
| 7 |
+
from fastmcp.server.context import Context
|
| 8 |
from . import clients
|
| 9 |
|
| 10 |
__version__ = version("fastmcp")
|
| 11 |
+
__all__ = ["FastMCP", "Context", "clients"]
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/exceptions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom exceptions for FastMCP."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class FastMCPError(Exception):
|
| 5 |
+
"""Base error for FastMCP."""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ValidationError(FastMCPError):
|
| 9 |
+
"""Error in validating parameters or return values."""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ResourceError(FastMCPError):
|
| 13 |
+
"""Error in resource operations."""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ToolError(FastMCPError):
|
| 17 |
+
"""Error in tool operations."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class InvalidSignature(Exception):
|
| 21 |
+
"""Invalid signature for use with FastMCP."""
|
src/fastmcp/prompts/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from .prompt_manager import PromptManager
|
| 2 |
|
| 3 |
-
__all__ = ["PromptManager"]
|
|
|
|
| 1 |
+
from .base import Prompt
|
| 2 |
from .prompt_manager import PromptManager
|
| 3 |
|
| 4 |
+
__all__ = ["Prompt", "PromptManager"]
|
src/fastmcp/prompts/base.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base classes for FastMCP prompts."""
|
| 2 |
+
|
| 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 |
+
|
| 14 |
+
|
| 15 |
+
class Message(BaseModel):
|
| 16 |
+
"""Base class for all prompt messages."""
|
| 17 |
+
|
| 18 |
+
role: Literal["user", "assistant"]
|
| 19 |
+
content: CONTENT_TYPES
|
| 20 |
+
|
| 21 |
+
def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any):
|
| 22 |
+
if isinstance(content, str):
|
| 23 |
+
content = TextContent(type="text", text=content)
|
| 24 |
+
super().__init__(content=content, **kwargs)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class UserMessage(Message):
|
| 28 |
+
"""A message from the user."""
|
| 29 |
+
|
| 30 |
+
role: Literal["user", "assistant"] = "user"
|
| 31 |
+
|
| 32 |
+
def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any):
|
| 33 |
+
super().__init__(content=content, **kwargs)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class AssistantMessage(Message):
|
| 37 |
+
"""A message from the assistant."""
|
| 38 |
+
|
| 39 |
+
role: Literal["user", "assistant"] = "assistant"
|
| 40 |
+
|
| 41 |
+
def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any):
|
| 42 |
+
super().__init__(content=content, **kwargs)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
message_validator = TypeAdapter[UserMessage | AssistantMessage](
|
| 46 |
+
UserMessage | AssistantMessage
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
SyncPromptResult = (
|
| 50 |
+
str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
|
| 51 |
+
)
|
| 52 |
+
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class PromptArgument(BaseModel):
|
| 56 |
+
"""An argument that can be passed to a prompt."""
|
| 57 |
+
|
| 58 |
+
name: str = Field(description="Name of the argument")
|
| 59 |
+
description: str | None = Field(
|
| 60 |
+
None, description="Description of what the argument does"
|
| 61 |
+
)
|
| 62 |
+
required: bool = Field(
|
| 63 |
+
default=False, description="Whether the argument is required"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class Prompt(BaseModel):
|
| 68 |
+
"""A prompt template that can be rendered with parameters."""
|
| 69 |
+
|
| 70 |
+
name: str = Field(description="Name of the prompt")
|
| 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]] = Field(exclude=True)
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def from_function(
|
| 81 |
+
cls,
|
| 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 |
+
|
| 88 |
+
The function can return:
|
| 89 |
+
- A string (converted to a message)
|
| 90 |
+
- A Message object
|
| 91 |
+
- A dict (converted to a message)
|
| 92 |
+
- A sequence of any of the above
|
| 93 |
+
"""
|
| 94 |
+
func_name = name or fn.__name__
|
| 95 |
+
|
| 96 |
+
if func_name == "<lambda>":
|
| 97 |
+
raise ValueError("You must provide a name for lambda functions")
|
| 98 |
+
|
| 99 |
+
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 100 |
+
parameters = TypeAdapter(fn).json_schema()
|
| 101 |
+
|
| 102 |
+
# Convert parameters to PromptArguments
|
| 103 |
+
arguments: list[PromptArgument] = []
|
| 104 |
+
if "properties" in parameters:
|
| 105 |
+
for param_name, param in parameters["properties"].items():
|
| 106 |
+
required = param_name in parameters.get("required", [])
|
| 107 |
+
arguments.append(
|
| 108 |
+
PromptArgument(
|
| 109 |
+
name=param_name,
|
| 110 |
+
description=param.get("description"),
|
| 111 |
+
required=required,
|
| 112 |
+
)
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# ensure the arguments are properly cast
|
| 116 |
+
fn = validate_call(fn)
|
| 117 |
+
|
| 118 |
+
return cls(
|
| 119 |
+
name=func_name,
|
| 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]:
|
| 126 |
+
"""Render the prompt with arguments."""
|
| 127 |
+
# Validate required arguments
|
| 128 |
+
if self.arguments:
|
| 129 |
+
required = {arg.name for arg in self.arguments if arg.required}
|
| 130 |
+
provided = set(arguments or {})
|
| 131 |
+
missing = required - provided
|
| 132 |
+
if missing:
|
| 133 |
+
raise ValueError(f"Missing required arguments: {missing}")
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
# Call function and check if result is a coroutine
|
| 137 |
+
result = self.fn(**(arguments or {}))
|
| 138 |
+
if inspect.iscoroutine(result):
|
| 139 |
+
result = await result
|
| 140 |
+
|
| 141 |
+
# Validate messages
|
| 142 |
+
if not isinstance(result, list | tuple):
|
| 143 |
+
result = [result]
|
| 144 |
+
|
| 145 |
+
# Convert result to messages
|
| 146 |
+
messages: list[Message] = []
|
| 147 |
+
for msg in result: # type: ignore[reportUnknownVariableType]
|
| 148 |
+
try:
|
| 149 |
+
if isinstance(msg, Message):
|
| 150 |
+
messages.append(msg)
|
| 151 |
+
elif isinstance(msg, dict):
|
| 152 |
+
messages.append(message_validator.validate_python(msg))
|
| 153 |
+
elif isinstance(msg, str):
|
| 154 |
+
content = TextContent(type="text", text=msg)
|
| 155 |
+
messages.append(UserMessage(content=content))
|
| 156 |
+
else:
|
| 157 |
+
content = json.dumps(pydantic_core.to_jsonable_python(msg))
|
| 158 |
+
messages.append(Message(role="user", content=content))
|
| 159 |
+
except Exception:
|
| 160 |
+
raise ValueError(
|
| 161 |
+
f"Could not convert prompt result to message: {msg}"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
return messages
|
| 165 |
+
except Exception as e:
|
| 166 |
+
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -1,15 +1,53 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
from
|
| 4 |
|
| 5 |
-
|
|
|
|
| 6 |
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
def import_prompts(
|
| 15 |
self, manager: "PromptManager", prefix: str | None = None
|
|
|
|
| 1 |
+
"""Prompt management functionality."""
|
| 2 |
|
| 3 |
+
from typing import Any
|
| 4 |
|
| 5 |
+
from fastmcp.prompts.base import Message, Prompt
|
| 6 |
+
from fastmcp.utilities.logging import get_logger
|
| 7 |
|
| 8 |
+
logger = get_logger(__name__)
|
| 9 |
|
| 10 |
+
|
| 11 |
+
class PromptManager:
|
| 12 |
+
"""Manages FastMCP prompts."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, warn_on_duplicate_prompts: bool = True):
|
| 15 |
+
self._prompts: dict[str, Prompt] = {}
|
| 16 |
+
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
|
| 17 |
+
|
| 18 |
+
def get_prompt(self, name: str) -> Prompt | None:
|
| 19 |
+
"""Get prompt by name."""
|
| 20 |
+
return self._prompts.get(name)
|
| 21 |
+
|
| 22 |
+
def list_prompts(self) -> list[Prompt]:
|
| 23 |
+
"""List all registered prompts."""
|
| 24 |
+
return list(self._prompts.values())
|
| 25 |
+
|
| 26 |
+
def add_prompt(
|
| 27 |
+
self,
|
| 28 |
+
prompt: Prompt,
|
| 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.warn_on_duplicate_prompts:
|
| 36 |
+
logger.warning(f"Prompt already exists: {prompt.name}")
|
| 37 |
+
return existing
|
| 38 |
+
|
| 39 |
+
self._prompts[prompt.name] = prompt
|
| 40 |
+
return prompt
|
| 41 |
+
|
| 42 |
+
async def render_prompt(
|
| 43 |
+
self, name: str, arguments: dict[str, Any] | None = None
|
| 44 |
+
) -> list[Message]:
|
| 45 |
+
"""Render a prompt by name with arguments."""
|
| 46 |
+
prompt = self.get_prompt(name)
|
| 47 |
+
if not prompt:
|
| 48 |
+
raise ValueError(f"Unknown prompt: {name}")
|
| 49 |
+
|
| 50 |
+
return await prompt.render(arguments)
|
| 51 |
|
| 52 |
def import_prompts(
|
| 53 |
self, manager: "PromptManager", prefix: str | None = None
|
src/fastmcp/resources/__init__.py
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
|
|
| 1 |
from .resource_manager import ResourceManager
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import Resource
|
| 2 |
from .resource_manager import ResourceManager
|
| 3 |
+
from .templates import ResourceTemplate
|
| 4 |
+
from .types import (
|
| 5 |
+
BinaryResource,
|
| 6 |
+
DirectoryResource,
|
| 7 |
+
FileResource,
|
| 8 |
+
FunctionResource,
|
| 9 |
+
HttpResource,
|
| 10 |
+
TextResource,
|
| 11 |
+
)
|
| 12 |
|
| 13 |
+
__all__ = [
|
| 14 |
+
"Resource",
|
| 15 |
+
"TextResource",
|
| 16 |
+
"BinaryResource",
|
| 17 |
+
"FunctionResource",
|
| 18 |
+
"FileResource",
|
| 19 |
+
"HttpResource",
|
| 20 |
+
"DirectoryResource",
|
| 21 |
+
"ResourceTemplate",
|
| 22 |
+
"ResourceManager",
|
| 23 |
+
]
|
src/fastmcp/resources/base.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|
| 18 |
+
"""Base class for all resources."""
|
| 19 |
+
|
| 20 |
+
model_config = ConfigDict(validate_default=True)
|
| 21 |
+
|
| 22 |
+
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
|
| 23 |
+
default=..., description="URI of the resource"
|
| 24 |
+
)
|
| 25 |
+
name: str | None = Field(description="Name of the resource", default=None)
|
| 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",
|
| 32 |
+
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
@field_validator("name", mode="before")
|
| 36 |
+
@classmethod
|
| 37 |
+
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
|
| 38 |
+
"""Set default name from URI if not provided."""
|
| 39 |
+
if name:
|
| 40 |
+
return name
|
| 41 |
+
if uri := info.data.get("uri"):
|
| 42 |
+
return str(uri)
|
| 43 |
+
raise ValueError("Either name or uri must be provided")
|
| 44 |
+
|
| 45 |
+
@abc.abstractmethod
|
| 46 |
+
async def read(self) -> str | bytes:
|
| 47 |
+
"""Read the resource content."""
|
| 48 |
+
pass
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -1,14 +1,98 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
from
|
| 4 |
-
|
| 5 |
-
)
|
| 6 |
|
| 7 |
-
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def import_resources(
|
| 14 |
self, manager: "ResourceManager", prefix: str | None = None
|
|
|
|
| 1 |
+
"""Resource manager functionality."""
|
| 2 |
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
from typing import Any
|
|
|
|
| 5 |
|
| 6 |
+
from pydantic import AnyUrl
|
| 7 |
|
| 8 |
+
from fastmcp.resources.base import Resource
|
| 9 |
+
from fastmcp.resources.templates import ResourceTemplate
|
| 10 |
+
from fastmcp.utilities.logging import get_logger
|
| 11 |
|
| 12 |
+
logger = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ResourceManager:
|
| 16 |
+
"""Manages FastMCP resources."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, warn_on_duplicate_resources: bool = True):
|
| 19 |
+
self._resources: dict[str, Resource] = {}
|
| 20 |
+
self._templates: dict[str, ResourceTemplate] = {}
|
| 21 |
+
self.warn_on_duplicate_resources = warn_on_duplicate_resources
|
| 22 |
+
|
| 23 |
+
def add_resource(self, resource: Resource) -> Resource:
|
| 24 |
+
"""Add a resource to the manager.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
resource: A Resource instance to add
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
The added resource. If a resource with the same URI already exists,
|
| 31 |
+
returns the existing resource.
|
| 32 |
+
"""
|
| 33 |
+
logger.debug(
|
| 34 |
+
"Adding resource",
|
| 35 |
+
extra={
|
| 36 |
+
"uri": resource.uri,
|
| 37 |
+
"type": type(resource).__name__,
|
| 38 |
+
"resource_name": resource.name,
|
| 39 |
+
},
|
| 40 |
+
)
|
| 41 |
+
existing = self._resources.get(str(resource.uri))
|
| 42 |
+
if existing:
|
| 43 |
+
if self.warn_on_duplicate_resources:
|
| 44 |
+
logger.warning(f"Resource already exists: {resource.uri}")
|
| 45 |
+
return existing
|
| 46 |
+
self._resources[str(resource.uri)] = resource
|
| 47 |
+
return resource
|
| 48 |
+
|
| 49 |
+
def add_template(
|
| 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 |
+
"""Add a template from a function."""
|
| 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 |
+
|
| 68 |
+
async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
|
| 69 |
+
"""Get resource by URI, checking concrete resources first, then templates."""
|
| 70 |
+
uri_str = str(uri)
|
| 71 |
+
logger.debug("Getting resource", extra={"uri": uri_str})
|
| 72 |
+
|
| 73 |
+
# First check concrete resources
|
| 74 |
+
if resource := self._resources.get(uri_str):
|
| 75 |
+
return resource
|
| 76 |
+
|
| 77 |
+
# Then check templates
|
| 78 |
+
for template in self._templates.values():
|
| 79 |
+
if params := template.matches(uri_str):
|
| 80 |
+
try:
|
| 81 |
+
return await template.create_resource(uri_str, params)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
raise ValueError(f"Error creating resource from template: {e}")
|
| 84 |
+
|
| 85 |
+
raise ValueError(f"Unknown resource: {uri}")
|
| 86 |
+
|
| 87 |
+
def list_resources(self) -> list[Resource]:
|
| 88 |
+
"""List all registered resources."""
|
| 89 |
+
logger.debug("Listing resources", extra={"count": len(self._resources)})
|
| 90 |
+
return list(self._resources.values())
|
| 91 |
+
|
| 92 |
+
def list_templates(self) -> list[ResourceTemplate]:
|
| 93 |
+
"""List all registered templates."""
|
| 94 |
+
logger.debug("Listing templates", extra={"count": len(self._templates)})
|
| 95 |
+
return list(self._templates.values())
|
| 96 |
|
| 97 |
def import_resources(
|
| 98 |
self, manager: "ResourceManager", prefix: str | None = None
|
src/fastmcp/resources/templates.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resource template functionality."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 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):
|
| 16 |
+
"""A template for dynamically creating resources."""
|
| 17 |
+
|
| 18 |
+
uri_template: str = Field(
|
| 19 |
+
description="URI template with parameters (e.g. weather://{city}/current)"
|
| 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] = Field(exclude=True)
|
| 27 |
+
parameters: dict[str, Any] = Field(
|
| 28 |
+
description="JSON schema for function parameters"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
@classmethod
|
| 32 |
+
def from_function(
|
| 33 |
+
cls,
|
| 34 |
+
fn: Callable[..., Any],
|
| 35 |
+
uri_template: str,
|
| 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__
|
| 42 |
+
if func_name == "<lambda>":
|
| 43 |
+
raise ValueError("You must provide a name for lambda functions")
|
| 44 |
+
|
| 45 |
+
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 46 |
+
parameters = TypeAdapter(fn).json_schema()
|
| 47 |
+
|
| 48 |
+
# ensure the arguments are properly cast
|
| 49 |
+
fn = validate_call(fn)
|
| 50 |
+
|
| 51 |
+
return cls(
|
| 52 |
+
uri_template=uri_template,
|
| 53 |
+
name=func_name,
|
| 54 |
+
description=description or fn.__doc__ or "",
|
| 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:
|
| 61 |
+
"""Check if URI matches template and extract parameters."""
|
| 62 |
+
# Convert template to regex pattern
|
| 63 |
+
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
|
| 64 |
+
match = re.match(f"^{pattern}$", uri)
|
| 65 |
+
if match:
|
| 66 |
+
return match.groupdict()
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
|
| 70 |
+
"""Create a resource from the template with the given parameters."""
|
| 71 |
+
try:
|
| 72 |
+
# Call function and check if result is a coroutine
|
| 73 |
+
result = self.fn(**params)
|
| 74 |
+
if inspect.iscoroutine(result):
|
| 75 |
+
result = await result
|
| 76 |
+
|
| 77 |
+
return FunctionResource(
|
| 78 |
+
uri=uri, # type: ignore
|
| 79 |
+
name=self.name,
|
| 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}")
|
src/fastmcp/resources/types.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Concrete resource implementations."""
|
| 2 |
+
|
| 3 |
+
import inspect
|
| 4 |
+
import json
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import anyio
|
| 10 |
+
import anyio.to_thread
|
| 11 |
+
import httpx
|
| 12 |
+
import pydantic.json
|
| 13 |
+
import pydantic_core
|
| 14 |
+
from pydantic import Field, ValidationInfo
|
| 15 |
+
|
| 16 |
+
from fastmcp.resources.base import Resource
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TextResource(Resource):
|
| 20 |
+
"""A resource that reads from a string."""
|
| 21 |
+
|
| 22 |
+
text: str = Field(description="Text content of the resource")
|
| 23 |
+
|
| 24 |
+
async def read(self) -> str:
|
| 25 |
+
"""Read the text content."""
|
| 26 |
+
return self.text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class BinaryResource(Resource):
|
| 30 |
+
"""A resource that reads from bytes."""
|
| 31 |
+
|
| 32 |
+
data: bytes = Field(description="Binary content of the resource")
|
| 33 |
+
|
| 34 |
+
async def read(self) -> bytes:
|
| 35 |
+
"""Read the binary content."""
|
| 36 |
+
return self.data
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class FunctionResource(Resource):
|
| 40 |
+
"""A resource that defers data loading by wrapping a function.
|
| 41 |
+
|
| 42 |
+
The function is only called when the resource is read, allowing for lazy loading
|
| 43 |
+
of potentially expensive data. This is particularly useful when listing resources,
|
| 44 |
+
as the function won't be called until the resource is actually accessed.
|
| 45 |
+
|
| 46 |
+
The function can return:
|
| 47 |
+
- str for text content (default)
|
| 48 |
+
- bytes for binary content
|
| 49 |
+
- other types will be converted to JSON
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
fn: Callable[[], Any] = Field(exclude=True)
|
| 53 |
+
|
| 54 |
+
async def read(self) -> str | bytes:
|
| 55 |
+
"""Read the resource by calling the wrapped function."""
|
| 56 |
+
try:
|
| 57 |
+
result = (
|
| 58 |
+
await self.fn() if inspect.iscoroutinefunction(self.fn) else self.fn()
|
| 59 |
+
)
|
| 60 |
+
if isinstance(result, Resource):
|
| 61 |
+
return await result.read()
|
| 62 |
+
if isinstance(result, bytes):
|
| 63 |
+
return result
|
| 64 |
+
if isinstance(result, str):
|
| 65 |
+
return result
|
| 66 |
+
try:
|
| 67 |
+
return json.dumps(pydantic_core.to_jsonable_python(result))
|
| 68 |
+
except (TypeError, pydantic_core.PydanticSerializationError):
|
| 69 |
+
# If JSON serialization fails, try str()
|
| 70 |
+
return str(result)
|
| 71 |
+
except Exception as e:
|
| 72 |
+
raise ValueError(f"Error reading resource {self.uri}: {e}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class FileResource(Resource):
|
| 76 |
+
"""A resource that reads from a file.
|
| 77 |
+
|
| 78 |
+
Set is_binary=True to read file as binary data instead of text.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
path: Path = Field(description="Path to the file")
|
| 82 |
+
is_binary: bool = Field(
|
| 83 |
+
default=False,
|
| 84 |
+
description="Whether to read the file as binary data",
|
| 85 |
+
)
|
| 86 |
+
mime_type: str = Field(
|
| 87 |
+
default="text/plain",
|
| 88 |
+
description="MIME type of the resource content",
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
@pydantic.field_validator("path")
|
| 92 |
+
@classmethod
|
| 93 |
+
def validate_absolute_path(cls, path: Path) -> Path:
|
| 94 |
+
"""Ensure path is absolute."""
|
| 95 |
+
if not path.is_absolute():
|
| 96 |
+
raise ValueError("Path must be absolute")
|
| 97 |
+
return path
|
| 98 |
+
|
| 99 |
+
@pydantic.field_validator("is_binary")
|
| 100 |
+
@classmethod
|
| 101 |
+
def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
|
| 102 |
+
"""Set is_binary based on mime_type if not explicitly set."""
|
| 103 |
+
if is_binary:
|
| 104 |
+
return True
|
| 105 |
+
mime_type = info.data.get("mime_type", "text/plain")
|
| 106 |
+
return not mime_type.startswith("text/")
|
| 107 |
+
|
| 108 |
+
async def read(self) -> str | bytes:
|
| 109 |
+
"""Read the file content."""
|
| 110 |
+
try:
|
| 111 |
+
if self.is_binary:
|
| 112 |
+
return await anyio.to_thread.run_sync(self.path.read_bytes)
|
| 113 |
+
return await anyio.to_thread.run_sync(self.path.read_text)
|
| 114 |
+
except Exception as e:
|
| 115 |
+
raise ValueError(f"Error reading file {self.path}: {e}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class HttpResource(Resource):
|
| 119 |
+
"""A resource that reads from an HTTP endpoint."""
|
| 120 |
+
|
| 121 |
+
url: str = Field(description="URL to fetch content from")
|
| 122 |
+
mime_type: str = Field(
|
| 123 |
+
default="application/json", description="MIME type of the resource content"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
async def read(self) -> str | bytes:
|
| 127 |
+
"""Read the HTTP content."""
|
| 128 |
+
async with httpx.AsyncClient() as client:
|
| 129 |
+
response = await client.get(self.url)
|
| 130 |
+
response.raise_for_status()
|
| 131 |
+
return response.text
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class DirectoryResource(Resource):
|
| 135 |
+
"""A resource that lists files in a directory."""
|
| 136 |
+
|
| 137 |
+
path: Path = Field(description="Path to the directory")
|
| 138 |
+
recursive: bool = Field(
|
| 139 |
+
default=False, description="Whether to list files recursively"
|
| 140 |
+
)
|
| 141 |
+
pattern: str | None = Field(
|
| 142 |
+
default=None, description="Optional glob pattern to filter files"
|
| 143 |
+
)
|
| 144 |
+
mime_type: str = Field(
|
| 145 |
+
default="application/json", description="MIME type of the resource content"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
@pydantic.field_validator("path")
|
| 149 |
+
@classmethod
|
| 150 |
+
def validate_absolute_path(cls, path: Path) -> Path:
|
| 151 |
+
"""Ensure path is absolute."""
|
| 152 |
+
if not path.is_absolute():
|
| 153 |
+
raise ValueError("Path must be absolute")
|
| 154 |
+
return path
|
| 155 |
+
|
| 156 |
+
def list_files(self) -> list[Path]:
|
| 157 |
+
"""List files in the directory."""
|
| 158 |
+
if not self.path.exists():
|
| 159 |
+
raise FileNotFoundError(f"Directory not found: {self.path}")
|
| 160 |
+
if not self.path.is_dir():
|
| 161 |
+
raise NotADirectoryError(f"Not a directory: {self.path}")
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
if self.pattern:
|
| 165 |
+
return (
|
| 166 |
+
list(self.path.glob(self.pattern))
|
| 167 |
+
if not self.recursive
|
| 168 |
+
else list(self.path.rglob(self.pattern))
|
| 169 |
+
)
|
| 170 |
+
return (
|
| 171 |
+
list(self.path.glob("*"))
|
| 172 |
+
if not self.recursive
|
| 173 |
+
else list(self.path.rglob("*"))
|
| 174 |
+
)
|
| 175 |
+
except Exception as e:
|
| 176 |
+
raise ValueError(f"Error listing directory {self.path}: {e}")
|
| 177 |
+
|
| 178 |
+
async def read(self) -> str: # Always returns JSON string
|
| 179 |
+
"""Read the directory listing."""
|
| 180 |
+
try:
|
| 181 |
+
files = await anyio.to_thread.run_sync(self.list_files)
|
| 182 |
+
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
|
| 183 |
+
return json.dumps({"files": file_list}, indent=2)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
raise ValueError(f"Error reading directory {self.path}: {e}")
|
src/fastmcp/server/context.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
-
from
|
| 2 |
|
| 3 |
-
|
| 4 |
-
from
|
|
|
|
|
|
|
| 5 |
from mcp.server.session import ServerSessionT
|
| 6 |
from mcp.shared.context import LifespanContextT, RequestContext
|
| 7 |
from mcp.types import (
|
|
@@ -10,19 +12,168 @@ from mcp.types import (
|
|
| 10 |
SamplingMessage,
|
| 11 |
TextContent,
|
| 12 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
logger = get_logger(__name__)
|
| 15 |
|
| 16 |
|
| 17 |
-
class Context(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def __init__(
|
| 19 |
self,
|
| 20 |
*,
|
| 21 |
request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None,
|
| 22 |
-
fastmcp:
|
| 23 |
**kwargs: Any,
|
| 24 |
):
|
| 25 |
-
super().__init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
async def sample(
|
| 28 |
self,
|
|
|
|
| 1 |
+
from __future__ import annotations as _annotations
|
| 2 |
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
+
from typing import Any, Generic, Literal
|
| 5 |
+
|
| 6 |
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 7 |
from mcp.server.session import ServerSessionT
|
| 8 |
from mcp.shared.context import LifespanContextT, RequestContext
|
| 9 |
from mcp.types import (
|
|
|
|
| 12 |
SamplingMessage,
|
| 13 |
TextContent,
|
| 14 |
)
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
from pydantic.networks import AnyUrl
|
| 17 |
+
|
| 18 |
+
from fastmcp.server.server import FastMCP
|
| 19 |
+
from fastmcp.utilities.logging import get_logger
|
| 20 |
|
| 21 |
logger = get_logger(__name__)
|
| 22 |
|
| 23 |
|
| 24 |
+
class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
| 25 |
+
"""Context object providing access to MCP capabilities.
|
| 26 |
+
|
| 27 |
+
This provides a cleaner interface to MCP's RequestContext functionality.
|
| 28 |
+
It gets injected into tool and resource functions that request it via type hints.
|
| 29 |
+
|
| 30 |
+
To use context in a tool function, add a parameter with the Context type annotation:
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
@server.tool()
|
| 34 |
+
def my_tool(x: int, ctx: Context) -> str:
|
| 35 |
+
# Log messages to the client
|
| 36 |
+
ctx.info(f"Processing {x}")
|
| 37 |
+
ctx.debug("Debug info")
|
| 38 |
+
ctx.warning("Warning message")
|
| 39 |
+
ctx.error("Error message")
|
| 40 |
+
|
| 41 |
+
# Report progress
|
| 42 |
+
ctx.report_progress(50, 100)
|
| 43 |
+
|
| 44 |
+
# Access resources
|
| 45 |
+
data = ctx.read_resource("resource://data")
|
| 46 |
+
|
| 47 |
+
# Get request info
|
| 48 |
+
request_id = ctx.request_id
|
| 49 |
+
client_id = ctx.client_id
|
| 50 |
+
|
| 51 |
+
return str(x)
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
The context parameter name can be anything as long as it's annotated with Context.
|
| 55 |
+
The context is optional - tools that don't need it can omit the parameter.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
_request_context: RequestContext[ServerSessionT, LifespanContextT] | None
|
| 59 |
+
_fastmcp: FastMCP | None
|
| 60 |
+
|
| 61 |
def __init__(
|
| 62 |
self,
|
| 63 |
*,
|
| 64 |
request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None,
|
| 65 |
+
fastmcp: FastMCP | None = None,
|
| 66 |
**kwargs: Any,
|
| 67 |
):
|
| 68 |
+
super().__init__(**kwargs)
|
| 69 |
+
self._request_context = request_context
|
| 70 |
+
self._fastmcp = fastmcp
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def fastmcp(self) -> FastMCP:
|
| 74 |
+
"""Access to the FastMCP server."""
|
| 75 |
+
if self._fastmcp is None:
|
| 76 |
+
raise ValueError("Context is not available outside of a request")
|
| 77 |
+
return self._fastmcp
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def request_context(self) -> RequestContext[ServerSessionT, LifespanContextT]:
|
| 81 |
+
"""Access to the underlying request context."""
|
| 82 |
+
if self._request_context is None:
|
| 83 |
+
raise ValueError("Context is not available outside of a request")
|
| 84 |
+
return self._request_context
|
| 85 |
+
|
| 86 |
+
async def report_progress(
|
| 87 |
+
self, progress: float, total: float | None = None
|
| 88 |
+
) -> None:
|
| 89 |
+
"""Report progress for the current operation.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
progress: Current progress value e.g. 24
|
| 93 |
+
total: Optional total value e.g. 100
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
progress_token = (
|
| 97 |
+
self.request_context.meta.progressToken
|
| 98 |
+
if self.request_context.meta
|
| 99 |
+
else None
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
if progress_token is None:
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
await self.request_context.session.send_progress_notification(
|
| 106 |
+
progress_token=progress_token, progress=progress, total=total
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
|
| 110 |
+
"""Read a resource by URI.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
uri: Resource URI to read
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
The resource content as either text or bytes
|
| 117 |
+
"""
|
| 118 |
+
assert self._fastmcp is not None, (
|
| 119 |
+
"Context is not available outside of a request"
|
| 120 |
+
)
|
| 121 |
+
return await self._fastmcp.read_resource(uri)
|
| 122 |
+
|
| 123 |
+
async def log(
|
| 124 |
+
self,
|
| 125 |
+
level: Literal["debug", "info", "warning", "error"],
|
| 126 |
+
message: str,
|
| 127 |
+
*,
|
| 128 |
+
logger_name: str | None = None,
|
| 129 |
+
) -> None:
|
| 130 |
+
"""Send a log message to the client.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
level: Log level (debug, info, warning, error)
|
| 134 |
+
message: Log message
|
| 135 |
+
logger_name: Optional logger name
|
| 136 |
+
**extra: Additional structured data to include
|
| 137 |
+
"""
|
| 138 |
+
await self.request_context.session.send_log_message(
|
| 139 |
+
level=level, data=message, logger=logger_name
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
@property
|
| 143 |
+
def client_id(self) -> str | None:
|
| 144 |
+
"""Get the client ID if available."""
|
| 145 |
+
return (
|
| 146 |
+
getattr(self.request_context.meta, "client_id", None)
|
| 147 |
+
if self.request_context.meta
|
| 148 |
+
else None
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
@property
|
| 152 |
+
def request_id(self) -> str:
|
| 153 |
+
"""Get the unique ID for this request."""
|
| 154 |
+
return str(self.request_context.request_id)
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def session(self):
|
| 158 |
+
"""Access to the underlying session for advanced usage."""
|
| 159 |
+
return self.request_context.session
|
| 160 |
+
|
| 161 |
+
# Convenience methods for common log levels
|
| 162 |
+
async def debug(self, message: str, **extra: Any) -> None:
|
| 163 |
+
"""Send a debug log message."""
|
| 164 |
+
await self.log("debug", message, **extra)
|
| 165 |
+
|
| 166 |
+
async def info(self, message: str, **extra: Any) -> None:
|
| 167 |
+
"""Send an info log message."""
|
| 168 |
+
await self.log("info", message, **extra)
|
| 169 |
+
|
| 170 |
+
async def warning(self, message: str, **extra: Any) -> None:
|
| 171 |
+
"""Send a warning log message."""
|
| 172 |
+
await self.log("warning", message, **extra)
|
| 173 |
+
|
| 174 |
+
async def error(self, message: str, **extra: Any) -> None:
|
| 175 |
+
"""Send an error log message."""
|
| 176 |
+
await self.log("error", message, **extra)
|
| 177 |
|
| 178 |
async def sample(
|
| 179 |
self,
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
from typing import Any, cast
|
| 2 |
|
| 3 |
import mcp.types
|
| 4 |
-
from mcp.server.fastmcp.prompts import Prompt
|
| 5 |
-
from mcp.server.fastmcp.resources import Resource, ResourceTemplate
|
| 6 |
-
from mcp.server.fastmcp.tools.base import Tool
|
| 7 |
-
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
|
| 8 |
from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
|
| 9 |
|
| 10 |
from fastmcp.clients.base import BaseClient
|
|
|
|
|
|
|
| 11 |
from fastmcp.server.context import Context
|
| 12 |
from fastmcp.server.server import FastMCP
|
|
|
|
|
|
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
logger = get_logger(__name__)
|
|
|
|
| 1 |
from typing import Any, cast
|
| 2 |
|
| 3 |
import mcp.types
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
|
| 5 |
|
| 6 |
from fastmcp.clients.base import BaseClient
|
| 7 |
+
from fastmcp.prompts import Prompt
|
| 8 |
+
from fastmcp.resources import Resource, ResourceTemplate
|
| 9 |
from fastmcp.server.context import Context
|
| 10 |
from fastmcp.server.server import FastMCP
|
| 11 |
+
from fastmcp.tools.base import Tool
|
| 12 |
+
from fastmcp.utilities.func_metadata import func_metadata
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
logger = get_logger(__name__)
|
src/fastmcp/server/server.py
CHANGED
|
@@ -1,28 +1,93 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
import mcp.types
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
from
|
| 10 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
if TYPE_CHECKING:
|
| 13 |
from fastmcp.clients.base import BaseClient
|
| 14 |
-
|
| 15 |
-
from .proxy import FastMCPProxy
|
| 16 |
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
self._tool_manager = ToolManager(
|
| 27 |
warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
|
| 28 |
)
|
|
@@ -32,21 +97,415 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
|
|
| 32 |
self._prompt_manager = PromptManager(
|
| 33 |
warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
|
| 34 |
)
|
|
|
|
| 35 |
|
| 36 |
# Setup for mounted apps
|
| 37 |
-
self._mounted_apps:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
Returns a Context object. Note that the context will only be valid
|
| 42 |
during a request; outside a request, most methods will error.
|
| 43 |
"""
|
|
|
|
| 44 |
try:
|
| 45 |
request_context = self._mcp_server.request_context
|
| 46 |
except LookupError:
|
| 47 |
request_context = None
|
|
|
|
|
|
|
| 48 |
return Context(request_context=request_context, fastmcp=self)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def mount(self, prefix: str, app: "FastMCP") -> None:
|
| 51 |
"""Mount another FastMCP application with a given prefix.
|
| 52 |
|
|
@@ -105,3 +564,28 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
|
|
| 105 |
from .proxy import FastMCPProxy
|
| 106 |
|
| 107 |
return await FastMCPProxy.from_client(client=client, **settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations as _annotations
|
|
|
|
| 4 |
|
| 5 |
+
import inspect
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
| 9 |
+
from contextlib import (
|
| 10 |
+
AbstractAsyncContextManager,
|
| 11 |
+
asynccontextmanager,
|
| 12 |
+
)
|
| 13 |
+
from itertools import chain
|
| 14 |
+
from typing import TYPE_CHECKING, Any, Generic, Literal
|
| 15 |
+
|
| 16 |
+
import anyio
|
| 17 |
+
import pydantic_core
|
| 18 |
+
import uvicorn
|
| 19 |
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 20 |
+
from mcp.server.lowlevel.server import LifespanResultT
|
| 21 |
+
from mcp.server.lowlevel.server import Server as MCPServer
|
| 22 |
+
from mcp.server.lowlevel.server import lifespan as default_lifespan
|
| 23 |
+
from mcp.server.session import ServerSession
|
| 24 |
+
from mcp.server.sse import SseServerTransport
|
| 25 |
+
from mcp.server.stdio import stdio_server
|
| 26 |
+
from mcp.types import (
|
| 27 |
+
AnyFunction,
|
| 28 |
+
EmbeddedResource,
|
| 29 |
+
GetPromptResult,
|
| 30 |
+
ImageContent,
|
| 31 |
+
TextContent,
|
| 32 |
+
)
|
| 33 |
+
from mcp.types import Prompt as MCPPrompt
|
| 34 |
+
from mcp.types import PromptArgument as MCPPromptArgument
|
| 35 |
+
from mcp.types import Resource as MCPResource
|
| 36 |
+
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
| 37 |
+
from mcp.types import Tool as MCPTool
|
| 38 |
+
from pydantic.networks import AnyUrl
|
| 39 |
+
from starlette.applications import Starlette
|
| 40 |
+
from starlette.requests import Request
|
| 41 |
+
from starlette.routing import Mount, Route
|
| 42 |
+
|
| 43 |
+
import fastmcp
|
| 44 |
+
import fastmcp.settings
|
| 45 |
+
from fastmcp.exceptions import ResourceError
|
| 46 |
+
from fastmcp.prompts import Prompt, PromptManager
|
| 47 |
+
from fastmcp.resources import FunctionResource, Resource, ResourceManager
|
| 48 |
+
from fastmcp.tools import ToolManager
|
| 49 |
+
from fastmcp.utilities.logging import configure_logging, get_logger
|
| 50 |
+
from fastmcp.utilities.types import Image
|
| 51 |
|
| 52 |
if TYPE_CHECKING:
|
| 53 |
from fastmcp.clients.base import BaseClient
|
| 54 |
+
from fastmcp.server.context import Context
|
| 55 |
+
from fastmcp.server.proxy import FastMCPProxy
|
| 56 |
|
| 57 |
logger = get_logger(__name__)
|
| 58 |
|
| 59 |
|
| 60 |
+
def lifespan_wrapper(
|
| 61 |
+
app: FastMCP,
|
| 62 |
+
lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]],
|
| 63 |
+
) -> Callable[
|
| 64 |
+
[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
|
| 65 |
+
]:
|
| 66 |
+
@asynccontextmanager
|
| 67 |
+
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
|
| 68 |
+
async with lifespan(app) as context:
|
| 69 |
+
yield context
|
| 70 |
+
|
| 71 |
+
return wrap
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class FastMCP(Generic[LifespanResultT]):
|
| 75 |
+
def __init__(
|
| 76 |
+
self,
|
| 77 |
+
name: str | None = None,
|
| 78 |
+
instructions: str | None = None,
|
| 79 |
+
lifespan: (
|
| 80 |
+
Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None
|
| 81 |
+
) = None,
|
| 82 |
+
**settings: Any,
|
| 83 |
+
):
|
| 84 |
+
self.settings = fastmcp.settings.ServerSettings(**settings)
|
| 85 |
|
| 86 |
+
self._mcp_server = MCPServer[LifespanResultT](
|
| 87 |
+
name=name or "FastMCP",
|
| 88 |
+
instructions=instructions,
|
| 89 |
+
lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
|
| 90 |
+
)
|
| 91 |
self._tool_manager = ToolManager(
|
| 92 |
warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
|
| 93 |
)
|
|
|
|
| 97 |
self._prompt_manager = PromptManager(
|
| 98 |
warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
|
| 99 |
)
|
| 100 |
+
self.dependencies = self.settings.dependencies
|
| 101 |
|
| 102 |
# Setup for mounted apps
|
| 103 |
+
self._mounted_apps: dict[str, "FastMCP"] = {}
|
| 104 |
+
|
| 105 |
+
# Set up MCP protocol handlers
|
| 106 |
+
self._setup_handlers()
|
| 107 |
+
|
| 108 |
+
# Configure logging
|
| 109 |
+
configure_logging(self.settings.log_level)
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def name(self) -> str:
|
| 113 |
+
return self._mcp_server.name
|
| 114 |
+
|
| 115 |
+
@property
|
| 116 |
+
def instructions(self) -> str | None:
|
| 117 |
+
return self._mcp_server.instructions
|
| 118 |
+
|
| 119 |
+
def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
|
| 120 |
+
"""Run the FastMCP server. Note this is a synchronous function.
|
| 121 |
|
| 122 |
+
Args:
|
| 123 |
+
transport: Transport protocol to use ("stdio" or "sse")
|
| 124 |
+
"""
|
| 125 |
+
TRANSPORTS = Literal["stdio", "sse"]
|
| 126 |
+
if transport not in TRANSPORTS.__args__: # type: ignore
|
| 127 |
+
raise ValueError(f"Unknown transport: {transport}")
|
| 128 |
+
|
| 129 |
+
if transport == "stdio":
|
| 130 |
+
anyio.run(self.run_stdio_async)
|
| 131 |
+
else: # transport == "sse"
|
| 132 |
+
anyio.run(self.run_sse_async)
|
| 133 |
+
|
| 134 |
+
def _setup_handlers(self) -> None:
|
| 135 |
+
"""Set up core MCP protocol handlers."""
|
| 136 |
+
self._mcp_server.list_tools()(self.list_tools)
|
| 137 |
+
self._mcp_server.call_tool()(self.call_tool)
|
| 138 |
+
self._mcp_server.list_resources()(self.list_resources)
|
| 139 |
+
self._mcp_server.read_resource()(self.read_resource)
|
| 140 |
+
self._mcp_server.list_prompts()(self.list_prompts)
|
| 141 |
+
self._mcp_server.get_prompt()(self.get_prompt)
|
| 142 |
+
self._mcp_server.list_resource_templates()(self.list_resource_templates)
|
| 143 |
+
|
| 144 |
+
async def list_tools(self) -> list[MCPTool]:
|
| 145 |
+
"""List all available tools."""
|
| 146 |
+
tools = self._tool_manager.list_tools()
|
| 147 |
+
return [
|
| 148 |
+
MCPTool(
|
| 149 |
+
name=info.name,
|
| 150 |
+
description=info.description,
|
| 151 |
+
inputSchema=info.parameters,
|
| 152 |
+
)
|
| 153 |
+
for info in tools
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
def get_context(self) -> "Context[ServerSession, LifespanResultT]":
|
| 157 |
"""
|
| 158 |
Returns a Context object. Note that the context will only be valid
|
| 159 |
during a request; outside a request, most methods will error.
|
| 160 |
"""
|
| 161 |
+
|
| 162 |
try:
|
| 163 |
request_context = self._mcp_server.request_context
|
| 164 |
except LookupError:
|
| 165 |
request_context = None
|
| 166 |
+
from fastmcp.server.context import Context
|
| 167 |
+
|
| 168 |
return Context(request_context=request_context, fastmcp=self)
|
| 169 |
|
| 170 |
+
async def call_tool(
|
| 171 |
+
self, name: str, arguments: dict[str, Any]
|
| 172 |
+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
|
| 173 |
+
"""Call a tool by name with arguments."""
|
| 174 |
+
context = self.get_context()
|
| 175 |
+
result = await self._tool_manager.call_tool(name, arguments, context=context)
|
| 176 |
+
converted_result = _convert_to_content(result)
|
| 177 |
+
return converted_result
|
| 178 |
+
|
| 179 |
+
async def list_resources(self) -> list[MCPResource]:
|
| 180 |
+
"""List all available resources."""
|
| 181 |
+
|
| 182 |
+
resources = self._resource_manager.list_resources()
|
| 183 |
+
return [
|
| 184 |
+
MCPResource(
|
| 185 |
+
uri=resource.uri,
|
| 186 |
+
name=resource.name or "",
|
| 187 |
+
description=resource.description,
|
| 188 |
+
mimeType=resource.mime_type,
|
| 189 |
+
)
|
| 190 |
+
for resource in resources
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
async def list_resource_templates(self) -> list[MCPResourceTemplate]:
|
| 194 |
+
templates = self._resource_manager.list_templates()
|
| 195 |
+
return [
|
| 196 |
+
MCPResourceTemplate(
|
| 197 |
+
uriTemplate=template.uri_template,
|
| 198 |
+
name=template.name,
|
| 199 |
+
description=template.description,
|
| 200 |
+
)
|
| 201 |
+
for template in templates
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]:
|
| 205 |
+
"""Read a resource by URI."""
|
| 206 |
+
|
| 207 |
+
resource = await self._resource_manager.get_resource(uri)
|
| 208 |
+
if not resource:
|
| 209 |
+
raise ResourceError(f"Unknown resource: {uri}")
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
content = await resource.read()
|
| 213 |
+
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logger.error(f"Error reading resource {uri}: {e}")
|
| 216 |
+
raise ResourceError(str(e))
|
| 217 |
+
|
| 218 |
+
def add_tool(
|
| 219 |
+
self,
|
| 220 |
+
fn: AnyFunction,
|
| 221 |
+
name: str | None = None,
|
| 222 |
+
description: str | None = None,
|
| 223 |
+
) -> None:
|
| 224 |
+
"""Add a tool to the server.
|
| 225 |
+
|
| 226 |
+
The tool function can optionally request a Context object by adding a parameter
|
| 227 |
+
with the Context type annotation. See the @tool decorator for examples.
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
fn: The function to register as a tool
|
| 231 |
+
name: Optional name for the tool (defaults to function name)
|
| 232 |
+
description: Optional description of what the tool does
|
| 233 |
+
"""
|
| 234 |
+
self._tool_manager.add_tool(fn, name=name, description=description)
|
| 235 |
+
|
| 236 |
+
def tool(
|
| 237 |
+
self, name: str | None = None, description: str | None = None
|
| 238 |
+
) -> Callable[[AnyFunction], AnyFunction]:
|
| 239 |
+
"""Decorator to register a tool.
|
| 240 |
+
|
| 241 |
+
Tools can optionally request a Context object by adding a parameter with the
|
| 242 |
+
Context type annotation. The context provides access to MCP capabilities like
|
| 243 |
+
logging, progress reporting, and resource access.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
name: Optional name for the tool (defaults to function name)
|
| 247 |
+
description: Optional description of what the tool does
|
| 248 |
+
|
| 249 |
+
Example:
|
| 250 |
+
@server.tool()
|
| 251 |
+
def my_tool(x: int) -> str:
|
| 252 |
+
return str(x)
|
| 253 |
+
|
| 254 |
+
@server.tool()
|
| 255 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 256 |
+
ctx.info(f"Processing {x}")
|
| 257 |
+
return str(x)
|
| 258 |
+
|
| 259 |
+
@server.tool()
|
| 260 |
+
async def async_tool(x: int, context: Context) -> str:
|
| 261 |
+
await context.report_progress(50, 100)
|
| 262 |
+
return str(x)
|
| 263 |
+
"""
|
| 264 |
+
# Check if user passed function directly instead of calling decorator
|
| 265 |
+
if callable(name):
|
| 266 |
+
raise TypeError(
|
| 267 |
+
"The @tool decorator was used incorrectly. "
|
| 268 |
+
"Did you forget to call it? Use @tool() instead of @tool"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 272 |
+
self.add_tool(fn, name=name, description=description)
|
| 273 |
+
return fn
|
| 274 |
+
|
| 275 |
+
return decorator
|
| 276 |
+
|
| 277 |
+
def add_resource(self, resource: Resource) -> None:
|
| 278 |
+
"""Add a resource to the server.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
resource: A Resource instance to add
|
| 282 |
+
"""
|
| 283 |
+
self._resource_manager.add_resource(resource)
|
| 284 |
+
|
| 285 |
+
def resource(
|
| 286 |
+
self,
|
| 287 |
+
uri: str,
|
| 288 |
+
*,
|
| 289 |
+
name: str | None = None,
|
| 290 |
+
description: str | None = None,
|
| 291 |
+
mime_type: str | None = None,
|
| 292 |
+
) -> Callable[[AnyFunction], AnyFunction]:
|
| 293 |
+
"""Decorator to register a function as a resource.
|
| 294 |
+
|
| 295 |
+
The function will be called when the resource is read to generate its content.
|
| 296 |
+
The function can return:
|
| 297 |
+
- str for text content
|
| 298 |
+
- bytes for binary content
|
| 299 |
+
- other types will be converted to JSON
|
| 300 |
+
|
| 301 |
+
If the URI contains parameters (e.g. "resource://{param}") or the function
|
| 302 |
+
has parameters, it will be registered as a template resource.
|
| 303 |
+
|
| 304 |
+
Args:
|
| 305 |
+
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
|
| 306 |
+
name: Optional name for the resource
|
| 307 |
+
description: Optional description of the resource
|
| 308 |
+
mime_type: Optional MIME type for the resource
|
| 309 |
+
|
| 310 |
+
Example:
|
| 311 |
+
@server.resource("resource://my-resource")
|
| 312 |
+
def get_data() -> str:
|
| 313 |
+
return "Hello, world!"
|
| 314 |
+
|
| 315 |
+
@server.resource("resource://my-resource")
|
| 316 |
+
async get_data() -> str:
|
| 317 |
+
data = await fetch_data()
|
| 318 |
+
return f"Hello, world! {data}"
|
| 319 |
+
|
| 320 |
+
@server.resource("resource://{city}/weather")
|
| 321 |
+
def get_weather(city: str) -> str:
|
| 322 |
+
return f"Weather for {city}"
|
| 323 |
+
|
| 324 |
+
@server.resource("resource://{city}/weather")
|
| 325 |
+
async def get_weather(city: str) -> str:
|
| 326 |
+
data = await fetch_weather(city)
|
| 327 |
+
return f"Weather for {city}: {data}"
|
| 328 |
+
"""
|
| 329 |
+
# Check if user passed function directly instead of calling decorator
|
| 330 |
+
if callable(uri):
|
| 331 |
+
raise TypeError(
|
| 332 |
+
"The @resource decorator was used incorrectly. "
|
| 333 |
+
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 337 |
+
# Check if this should be a template
|
| 338 |
+
has_uri_params = "{" in uri and "}" in uri
|
| 339 |
+
has_func_params = bool(inspect.signature(fn).parameters)
|
| 340 |
+
|
| 341 |
+
if has_uri_params or has_func_params:
|
| 342 |
+
# Validate that URI params match function params
|
| 343 |
+
uri_params = set(re.findall(r"{(\w+)}", uri))
|
| 344 |
+
func_params = set(inspect.signature(fn).parameters.keys())
|
| 345 |
+
|
| 346 |
+
if uri_params != func_params:
|
| 347 |
+
raise ValueError(
|
| 348 |
+
f"Mismatch between URI parameters {uri_params} "
|
| 349 |
+
f"and function parameters {func_params}"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# Register as template
|
| 353 |
+
self._resource_manager.add_template(
|
| 354 |
+
fn=fn,
|
| 355 |
+
uri_template=uri,
|
| 356 |
+
name=name,
|
| 357 |
+
description=description,
|
| 358 |
+
mime_type=mime_type or "text/plain",
|
| 359 |
+
)
|
| 360 |
+
else:
|
| 361 |
+
# Register as regular resource
|
| 362 |
+
resource = FunctionResource(
|
| 363 |
+
uri=AnyUrl(uri),
|
| 364 |
+
name=name,
|
| 365 |
+
description=description,
|
| 366 |
+
mime_type=mime_type or "text/plain",
|
| 367 |
+
fn=fn,
|
| 368 |
+
)
|
| 369 |
+
self.add_resource(resource)
|
| 370 |
+
return fn
|
| 371 |
+
|
| 372 |
+
return decorator
|
| 373 |
+
|
| 374 |
+
def add_prompt(self, prompt: Prompt) -> None:
|
| 375 |
+
"""Add a prompt to the server.
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
prompt: A Prompt instance to add
|
| 379 |
+
"""
|
| 380 |
+
self._prompt_manager.add_prompt(prompt)
|
| 381 |
+
|
| 382 |
+
def prompt(
|
| 383 |
+
self, name: str | None = None, description: str | None = None
|
| 384 |
+
) -> Callable[[AnyFunction], AnyFunction]:
|
| 385 |
+
"""Decorator to register a prompt.
|
| 386 |
+
|
| 387 |
+
Args:
|
| 388 |
+
name: Optional name for the prompt (defaults to function name)
|
| 389 |
+
description: Optional description of what the prompt does
|
| 390 |
+
|
| 391 |
+
Example:
|
| 392 |
+
@server.prompt()
|
| 393 |
+
def analyze_table(table_name: str) -> list[Message]:
|
| 394 |
+
schema = read_table_schema(table_name)
|
| 395 |
+
return [
|
| 396 |
+
{
|
| 397 |
+
"role": "user",
|
| 398 |
+
"content": f"Analyze this schema:\n{schema}"
|
| 399 |
+
}
|
| 400 |
+
]
|
| 401 |
+
|
| 402 |
+
@server.prompt()
|
| 403 |
+
async def analyze_file(path: str) -> list[Message]:
|
| 404 |
+
content = await read_file(path)
|
| 405 |
+
return [
|
| 406 |
+
{
|
| 407 |
+
"role": "user",
|
| 408 |
+
"content": {
|
| 409 |
+
"type": "resource",
|
| 410 |
+
"resource": {
|
| 411 |
+
"uri": f"file://{path}",
|
| 412 |
+
"text": content
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
]
|
| 417 |
+
"""
|
| 418 |
+
# Check if user passed function directly instead of calling decorator
|
| 419 |
+
if callable(name):
|
| 420 |
+
raise TypeError(
|
| 421 |
+
"The @prompt decorator was used incorrectly. "
|
| 422 |
+
"Did you forget to call it? Use @prompt() instead of @prompt"
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
def decorator(func: AnyFunction) -> AnyFunction:
|
| 426 |
+
prompt = Prompt.from_function(func, name=name, description=description)
|
| 427 |
+
self.add_prompt(prompt)
|
| 428 |
+
return func
|
| 429 |
+
|
| 430 |
+
return decorator
|
| 431 |
+
|
| 432 |
+
async def run_stdio_async(self) -> None:
|
| 433 |
+
"""Run the server using stdio transport."""
|
| 434 |
+
async with stdio_server() as (read_stream, write_stream):
|
| 435 |
+
await self._mcp_server.run(
|
| 436 |
+
read_stream,
|
| 437 |
+
write_stream,
|
| 438 |
+
self._mcp_server.create_initialization_options(),
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
async def run_sse_async(self) -> None:
|
| 442 |
+
"""Run the server using SSE transport."""
|
| 443 |
+
starlette_app = self.sse_app()
|
| 444 |
+
|
| 445 |
+
config = uvicorn.Config(
|
| 446 |
+
starlette_app,
|
| 447 |
+
host=self.settings.host,
|
| 448 |
+
port=self.settings.port,
|
| 449 |
+
log_level=self.settings.log_level.lower(),
|
| 450 |
+
)
|
| 451 |
+
server = uvicorn.Server(config)
|
| 452 |
+
await server.serve()
|
| 453 |
+
|
| 454 |
+
def sse_app(self) -> Starlette:
|
| 455 |
+
"""Return an instance of the SSE server app."""
|
| 456 |
+
sse = SseServerTransport(self.settings.message_path)
|
| 457 |
+
|
| 458 |
+
async def handle_sse(request: Request) -> None:
|
| 459 |
+
async with sse.connect_sse(
|
| 460 |
+
request.scope,
|
| 461 |
+
request.receive,
|
| 462 |
+
request._send, # type: ignore[reportPrivateUsage]
|
| 463 |
+
) as streams:
|
| 464 |
+
await self._mcp_server.run(
|
| 465 |
+
streams[0],
|
| 466 |
+
streams[1],
|
| 467 |
+
self._mcp_server.create_initialization_options(),
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
return Starlette(
|
| 471 |
+
debug=self.settings.debug,
|
| 472 |
+
routes=[
|
| 473 |
+
Route(self.settings.sse_path, endpoint=handle_sse),
|
| 474 |
+
Mount(self.settings.message_path, app=sse.handle_post_message),
|
| 475 |
+
],
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
async def list_prompts(self) -> list[MCPPrompt]:
|
| 479 |
+
"""List all available prompts."""
|
| 480 |
+
prompts = self._prompt_manager.list_prompts()
|
| 481 |
+
return [
|
| 482 |
+
MCPPrompt(
|
| 483 |
+
name=prompt.name,
|
| 484 |
+
description=prompt.description,
|
| 485 |
+
arguments=[
|
| 486 |
+
MCPPromptArgument(
|
| 487 |
+
name=arg.name,
|
| 488 |
+
description=arg.description,
|
| 489 |
+
required=arg.required,
|
| 490 |
+
)
|
| 491 |
+
for arg in (prompt.arguments or [])
|
| 492 |
+
],
|
| 493 |
+
)
|
| 494 |
+
for prompt in prompts
|
| 495 |
+
]
|
| 496 |
+
|
| 497 |
+
async def get_prompt(
|
| 498 |
+
self, name: str, arguments: dict[str, Any] | None = None
|
| 499 |
+
) -> GetPromptResult:
|
| 500 |
+
"""Get a prompt by name with arguments."""
|
| 501 |
+
try:
|
| 502 |
+
messages = await self._prompt_manager.render_prompt(name, arguments)
|
| 503 |
+
|
| 504 |
+
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
| 505 |
+
except Exception as e:
|
| 506 |
+
logger.error(f"Error getting prompt {name}: {e}")
|
| 507 |
+
raise ValueError(str(e))
|
| 508 |
+
|
| 509 |
def mount(self, prefix: str, app: "FastMCP") -> None:
|
| 510 |
"""Mount another FastMCP application with a given prefix.
|
| 511 |
|
|
|
|
| 564 |
from .proxy import FastMCPProxy
|
| 565 |
|
| 566 |
return await FastMCPProxy.from_client(client=client, **settings)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def _convert_to_content(
|
| 570 |
+
result: Any,
|
| 571 |
+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
|
| 572 |
+
"""Convert a result to a sequence of content objects."""
|
| 573 |
+
if result is None:
|
| 574 |
+
return []
|
| 575 |
+
|
| 576 |
+
if isinstance(result, TextContent | ImageContent | EmbeddedResource):
|
| 577 |
+
return [result]
|
| 578 |
+
|
| 579 |
+
if isinstance(result, Image):
|
| 580 |
+
return [result.to_image_content()]
|
| 581 |
+
|
| 582 |
+
if isinstance(result, list | tuple):
|
| 583 |
+
return list(chain.from_iterable(_convert_to_content(item) for item in result)) # type: ignore[reportUnknownVariableType]
|
| 584 |
+
|
| 585 |
+
if not isinstance(result, str):
|
| 586 |
+
try:
|
| 587 |
+
result = json.dumps(pydantic_core.to_jsonable_python(result))
|
| 588 |
+
except Exception:
|
| 589 |
+
result = str(result)
|
| 590 |
+
|
| 591 |
+
return [TextContent(type="text", text=result)]
|
src/fastmcp/settings.py
CHANGED
|
@@ -1,25 +1,73 @@
|
|
| 1 |
-
from
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
| 6 |
|
| 7 |
|
| 8 |
class Settings(BaseSettings):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""FastMCP server settings.
|
| 10 |
|
| 11 |
All settings can be configured via environment variables with the prefix FASTMCP_.
|
| 12 |
For example, FASTMCP_DEBUG=true will set debug=True.
|
| 13 |
"""
|
| 14 |
|
| 15 |
-
model_config
|
| 16 |
-
env_prefix="
|
| 17 |
env_file=".env",
|
| 18 |
extra="ignore",
|
| 19 |
)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
debug: bool = False
|
| 22 |
-
log_level: LOG_LEVEL = "INFO"
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations as _annotations
|
| 2 |
|
| 3 |
+
from typing import TYPE_CHECKING, Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import Field
|
| 6 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 7 |
|
| 8 |
+
if TYPE_CHECKING:
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
| 12 |
|
| 13 |
|
| 14 |
class Settings(BaseSettings):
|
| 15 |
+
"""FastMCP settings."""
|
| 16 |
+
|
| 17 |
+
model_config = SettingsConfigDict(
|
| 18 |
+
env_prefix="FASTMCP_",
|
| 19 |
+
env_file=".env",
|
| 20 |
+
extra="ignore",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
test_mode: bool = False
|
| 24 |
+
log_level: LOG_LEVEL = "INFO"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ServerSettings(BaseSettings):
|
| 28 |
"""FastMCP server settings.
|
| 29 |
|
| 30 |
All settings can be configured via environment variables with the prefix FASTMCP_.
|
| 31 |
For example, FASTMCP_DEBUG=true will set debug=True.
|
| 32 |
"""
|
| 33 |
|
| 34 |
+
model_config = SettingsConfigDict(
|
| 35 |
+
env_prefix="FASTMCP_SERVER_",
|
| 36 |
env_file=".env",
|
| 37 |
extra="ignore",
|
| 38 |
)
|
| 39 |
|
| 40 |
+
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
|
| 41 |
+
|
| 42 |
+
# HTTP settings
|
| 43 |
+
host: str = "0.0.0.0"
|
| 44 |
+
port: int = 8000
|
| 45 |
+
sse_path: str = "/sse"
|
| 46 |
+
message_path: str = "/messages/"
|
| 47 |
debug: bool = False
|
|
|
|
| 48 |
|
| 49 |
+
# resource settings
|
| 50 |
+
warn_on_duplicate_resources: bool = True
|
| 51 |
+
|
| 52 |
+
# tool settings
|
| 53 |
+
warn_on_duplicate_tools: bool = True
|
| 54 |
+
|
| 55 |
+
# prompt settings
|
| 56 |
+
warn_on_duplicate_prompts: bool = True
|
| 57 |
+
|
| 58 |
+
dependencies: list[str] = Field(
|
| 59 |
+
default_factory=list,
|
| 60 |
+
description="List of dependencies to install in the server environment",
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ClientSettings(BaseSettings):
|
| 65 |
+
"""FastMCP client settings."""
|
| 66 |
+
|
| 67 |
+
model_config = SettingsConfigDict(
|
| 68 |
+
env_prefix="FASTMCP_CLIENT_",
|
| 69 |
+
env_file=".env",
|
| 70 |
+
extra="ignore",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
|
src/fastmcp/tools/__init__.py
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import Tool
|
| 2 |
+
from .tool_manager import ToolManager
|
| 3 |
+
|
| 4 |
+
__all__ = ["Tool", "ToolManager"]
|
src/fastmcp/tools/base.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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
|
| 14 |
+
from mcp.shared.context import LifespanContextT
|
| 15 |
+
|
| 16 |
+
from fastmcp.server import Context
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Tool(BaseModel):
|
| 20 |
+
"""Internal tool registration info."""
|
| 21 |
+
|
| 22 |
+
fn: Callable[..., Any] = Field(exclude=True)
|
| 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")
|
| 26 |
+
fn_metadata: FuncMetadata = Field(
|
| 27 |
+
description="Metadata about the function including a pydantic model for tool"
|
| 28 |
+
" arguments"
|
| 29 |
+
)
|
| 30 |
+
is_async: bool = Field(description="Whether the tool is async")
|
| 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(
|
| 37 |
+
cls,
|
| 38 |
+
fn: Callable[..., Any],
|
| 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
|
| 45 |
+
|
| 46 |
+
func_name = name or fn.__name__
|
| 47 |
+
|
| 48 |
+
if func_name == "<lambda>":
|
| 49 |
+
raise ValueError("You must provide a name for lambda functions")
|
| 50 |
+
|
| 51 |
+
func_doc = description or fn.__doc__ or ""
|
| 52 |
+
is_async = inspect.iscoroutinefunction(fn)
|
| 53 |
+
|
| 54 |
+
if context_kwarg is None:
|
| 55 |
+
sig = inspect.signature(fn)
|
| 56 |
+
for param_name, param in sig.parameters.items():
|
| 57 |
+
if param.annotation is Context:
|
| 58 |
+
context_kwarg = param_name
|
| 59 |
+
break
|
| 60 |
+
|
| 61 |
+
func_arg_metadata = func_metadata(
|
| 62 |
+
fn,
|
| 63 |
+
skip_names=[context_kwarg] if context_kwarg is not None else [],
|
| 64 |
+
)
|
| 65 |
+
parameters = func_arg_metadata.arg_model.model_json_schema()
|
| 66 |
+
|
| 67 |
+
return cls(
|
| 68 |
+
fn=fn,
|
| 69 |
+
name=func_name,
|
| 70 |
+
description=func_doc,
|
| 71 |
+
parameters=parameters,
|
| 72 |
+
fn_metadata=func_arg_metadata,
|
| 73 |
+
is_async=is_async,
|
| 74 |
+
context_kwarg=context_kwarg,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
async def run(
|
| 78 |
+
self,
|
| 79 |
+
arguments: dict[str, Any],
|
| 80 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 81 |
+
) -> Any:
|
| 82 |
+
"""Run the tool with arguments."""
|
| 83 |
+
try:
|
| 84 |
+
return await self.fn_metadata.call_fn_with_arg_validation(
|
| 85 |
+
self.fn,
|
| 86 |
+
self.is_async,
|
| 87 |
+
arguments,
|
| 88 |
+
{self.context_kwarg: context}
|
| 89 |
+
if self.context_kwarg is not None
|
| 90 |
+
else None,
|
| 91 |
+
)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -1,16 +1,65 @@
|
|
| 1 |
-
import
|
| 2 |
-
from mcp.server.fastmcp.tools import Tool
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from fastmcp.utilities.logging import get_logger
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
logger = get_logger(__name__)
|
| 7 |
|
| 8 |
|
| 9 |
-
class ToolManager
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def import_tools(
|
| 16 |
self, tool_manager: "ToolManager", prefix: str | None = None
|
|
|
|
| 1 |
+
from __future__ import annotations as _annotations
|
|
|
|
| 2 |
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
from typing import TYPE_CHECKING, Any
|
| 5 |
+
|
| 6 |
+
from mcp.shared.context import LifespanContextT
|
| 7 |
+
|
| 8 |
+
from fastmcp.exceptions import ToolError
|
| 9 |
+
from fastmcp.tools.base import Tool
|
| 10 |
from fastmcp.utilities.logging import get_logger
|
| 11 |
|
| 12 |
+
if TYPE_CHECKING:
|
| 13 |
+
from mcp.server.session import ServerSessionT
|
| 14 |
+
|
| 15 |
+
from fastmcp.server import Context
|
| 16 |
+
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
| 19 |
|
| 20 |
+
class ToolManager:
|
| 21 |
+
"""Manages FastMCP tools."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, warn_on_duplicate_tools: bool = True):
|
| 24 |
+
self._tools: dict[str, Tool] = {}
|
| 25 |
+
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
| 26 |
+
|
| 27 |
+
def get_tool(self, name: str) -> Tool | None:
|
| 28 |
+
"""Get tool by name."""
|
| 29 |
+
return self._tools.get(name)
|
| 30 |
+
|
| 31 |
+
def list_tools(self) -> list[Tool]:
|
| 32 |
+
"""List all registered tools."""
|
| 33 |
+
return list(self._tools.values())
|
| 34 |
+
|
| 35 |
+
def add_tool(
|
| 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.warn_on_duplicate_tools:
|
| 46 |
+
logger.warning(f"Tool already exists: {tool.name}")
|
| 47 |
+
return existing
|
| 48 |
+
self._tools[tool.name] = tool
|
| 49 |
+
return tool
|
| 50 |
+
|
| 51 |
+
async def call_tool(
|
| 52 |
+
self,
|
| 53 |
+
name: str,
|
| 54 |
+
arguments: dict[str, Any],
|
| 55 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 56 |
+
) -> Any:
|
| 57 |
+
"""Call a tool by name with arguments."""
|
| 58 |
+
tool = self.get_tool(name)
|
| 59 |
+
if not tool:
|
| 60 |
+
raise ToolError(f"Unknown tool: {name}")
|
| 61 |
+
|
| 62 |
+
return await tool.run(arguments, context=context)
|
| 63 |
|
| 64 |
def import_tools(
|
| 65 |
self, tool_manager: "ToolManager", prefix: str | None = None
|
src/fastmcp/utilities/func_metadata.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
import json
|
| 3 |
+
from collections.abc import Awaitable, Callable, Sequence
|
| 4 |
+
from typing import (
|
| 5 |
+
Annotated,
|
| 6 |
+
Any,
|
| 7 |
+
ForwardRef,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
|
| 11 |
+
from pydantic._internal._typing_extra import eval_type_backport
|
| 12 |
+
from pydantic.fields import FieldInfo
|
| 13 |
+
from pydantic_core import PydanticUndefined
|
| 14 |
+
|
| 15 |
+
from fastmcp.exceptions import InvalidSignature
|
| 16 |
+
from fastmcp.utilities.logging import get_logger
|
| 17 |
+
|
| 18 |
+
logger = get_logger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ArgModelBase(BaseModel):
|
| 22 |
+
"""A model representing the arguments to a function."""
|
| 23 |
+
|
| 24 |
+
def model_dump_one_level(self) -> dict[str, Any]:
|
| 25 |
+
"""Return a dict of the model's fields, one level deep.
|
| 26 |
+
|
| 27 |
+
That is, sub-models etc are not dumped - they are kept as pydantic models.
|
| 28 |
+
"""
|
| 29 |
+
kwargs: dict[str, Any] = {}
|
| 30 |
+
for field_name in self.model_fields.keys():
|
| 31 |
+
kwargs[field_name] = getattr(self, field_name)
|
| 32 |
+
return kwargs
|
| 33 |
+
|
| 34 |
+
model_config = ConfigDict(
|
| 35 |
+
arbitrary_types_allowed=True,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class FuncMetadata(BaseModel):
|
| 40 |
+
arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
|
| 41 |
+
# We can add things in the future like
|
| 42 |
+
# - Maybe some args are excluded from attempting to parse from JSON
|
| 43 |
+
# - Maybe some args are special (like context) for dependency injection
|
| 44 |
+
|
| 45 |
+
async def call_fn_with_arg_validation(
|
| 46 |
+
self,
|
| 47 |
+
fn: Callable[..., Any] | Awaitable[Any],
|
| 48 |
+
fn_is_async: bool,
|
| 49 |
+
arguments_to_validate: dict[str, Any],
|
| 50 |
+
arguments_to_pass_directly: dict[str, Any] | None,
|
| 51 |
+
) -> Any:
|
| 52 |
+
"""Call the given function with arguments validated and injected.
|
| 53 |
+
|
| 54 |
+
Arguments are first attempted to be parsed from JSON, then validated against
|
| 55 |
+
the argument model, before being passed to the function.
|
| 56 |
+
"""
|
| 57 |
+
arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
|
| 58 |
+
arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
|
| 59 |
+
arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
|
| 60 |
+
|
| 61 |
+
arguments_parsed_dict |= arguments_to_pass_directly or {}
|
| 62 |
+
|
| 63 |
+
if fn_is_async:
|
| 64 |
+
if isinstance(fn, Awaitable):
|
| 65 |
+
return await fn
|
| 66 |
+
return await fn(**arguments_parsed_dict)
|
| 67 |
+
if isinstance(fn, Callable):
|
| 68 |
+
return fn(**arguments_parsed_dict)
|
| 69 |
+
raise TypeError("fn must be either Callable or Awaitable")
|
| 70 |
+
|
| 71 |
+
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
|
| 72 |
+
"""Pre-parse data from JSON.
|
| 73 |
+
|
| 74 |
+
Return a dict with same keys as input but with values parsed from JSON
|
| 75 |
+
if appropriate.
|
| 76 |
+
|
| 77 |
+
This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
|
| 78 |
+
a string rather than an actual list. Claude desktop is prone to this - in fact
|
| 79 |
+
it seems incapable of NOT doing this. For sub-models, it tends to pass
|
| 80 |
+
dicts (JSON objects) as JSON strings, which can be pre-parsed here.
|
| 81 |
+
"""
|
| 82 |
+
new_data = data.copy() # Shallow copy
|
| 83 |
+
for field_name, _field_info in self.arg_model.model_fields.items():
|
| 84 |
+
if field_name not in data.keys():
|
| 85 |
+
continue
|
| 86 |
+
if isinstance(data[field_name], str):
|
| 87 |
+
try:
|
| 88 |
+
pre_parsed = json.loads(data[field_name])
|
| 89 |
+
except json.JSONDecodeError:
|
| 90 |
+
continue # Not JSON - skip
|
| 91 |
+
if isinstance(pre_parsed, str | int | float):
|
| 92 |
+
# This is likely that the raw value is e.g. `"hello"` which we
|
| 93 |
+
# Should really be parsed as '"hello"' in Python - but if we parse
|
| 94 |
+
# it as JSON it'll turn into just 'hello'. So we skip it.
|
| 95 |
+
continue
|
| 96 |
+
new_data[field_name] = pre_parsed
|
| 97 |
+
assert new_data.keys() == data.keys()
|
| 98 |
+
return new_data
|
| 99 |
+
|
| 100 |
+
model_config = ConfigDict(
|
| 101 |
+
arbitrary_types_allowed=True,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def func_metadata(
|
| 106 |
+
func: Callable[..., Any], skip_names: Sequence[str] = ()
|
| 107 |
+
) -> FuncMetadata:
|
| 108 |
+
"""Given a function, return metadata including a pydantic model representing its
|
| 109 |
+
signature.
|
| 110 |
+
|
| 111 |
+
The use case for this is
|
| 112 |
+
```
|
| 113 |
+
meta = func_to_pyd(func)
|
| 114 |
+
validated_args = meta.arg_model.model_validate(some_raw_data_dict)
|
| 115 |
+
return func(**validated_args.model_dump_one_level())
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**critically** it also provides pre-parse helper to attempt to parse things from
|
| 119 |
+
JSON.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
func: The function to convert to a pydantic model
|
| 123 |
+
skip_names: A list of parameter names to skip. These will not be included in
|
| 124 |
+
the model.
|
| 125 |
+
Returns:
|
| 126 |
+
A pydantic model representing the function's signature.
|
| 127 |
+
"""
|
| 128 |
+
sig = _get_typed_signature(func)
|
| 129 |
+
params = sig.parameters
|
| 130 |
+
dynamic_pydantic_model_params: dict[str, Any] = {}
|
| 131 |
+
globalns = getattr(func, "__globals__", {})
|
| 132 |
+
for param in params.values():
|
| 133 |
+
if param.name.startswith("_"):
|
| 134 |
+
raise InvalidSignature(
|
| 135 |
+
f"Parameter {param.name} of {func.__name__} cannot start with '_'"
|
| 136 |
+
)
|
| 137 |
+
if param.name in skip_names:
|
| 138 |
+
continue
|
| 139 |
+
annotation = param.annotation
|
| 140 |
+
|
| 141 |
+
# `x: None` / `x: None = None`
|
| 142 |
+
if annotation is None:
|
| 143 |
+
annotation = Annotated[
|
| 144 |
+
None,
|
| 145 |
+
Field(
|
| 146 |
+
default=param.default
|
| 147 |
+
if param.default is not inspect.Parameter.empty
|
| 148 |
+
else PydanticUndefined
|
| 149 |
+
),
|
| 150 |
+
]
|
| 151 |
+
|
| 152 |
+
# Untyped field
|
| 153 |
+
if annotation is inspect.Parameter.empty:
|
| 154 |
+
annotation = Annotated[
|
| 155 |
+
Any,
|
| 156 |
+
Field(),
|
| 157 |
+
# 🤷
|
| 158 |
+
WithJsonSchema({"title": param.name, "type": "string"}),
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
field_info = FieldInfo.from_annotated_attribute(
|
| 162 |
+
_get_typed_annotation(annotation, globalns),
|
| 163 |
+
param.default
|
| 164 |
+
if param.default is not inspect.Parameter.empty
|
| 165 |
+
else PydanticUndefined,
|
| 166 |
+
)
|
| 167 |
+
dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
arguments_model = create_model(
|
| 171 |
+
f"{func.__name__}Arguments",
|
| 172 |
+
**dynamic_pydantic_model_params,
|
| 173 |
+
__base__=ArgModelBase,
|
| 174 |
+
)
|
| 175 |
+
resp = FuncMetadata(arg_model=arguments_model)
|
| 176 |
+
return resp
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
|
| 180 |
+
def try_eval_type(
|
| 181 |
+
value: Any, globalns: dict[str, Any], localns: dict[str, Any]
|
| 182 |
+
) -> tuple[Any, bool]:
|
| 183 |
+
try:
|
| 184 |
+
return eval_type_backport(value, globalns, localns), True
|
| 185 |
+
except NameError:
|
| 186 |
+
return value, False
|
| 187 |
+
|
| 188 |
+
if isinstance(annotation, str):
|
| 189 |
+
annotation = ForwardRef(annotation)
|
| 190 |
+
annotation, status = try_eval_type(annotation, globalns, globalns)
|
| 191 |
+
|
| 192 |
+
# This check and raise could perhaps be skipped, and we (FastMCP) just call
|
| 193 |
+
# model_rebuild right before using it 🤷
|
| 194 |
+
if status is False:
|
| 195 |
+
raise InvalidSignature(f"Unable to evaluate type annotation {annotation}")
|
| 196 |
+
|
| 197 |
+
return annotation
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
|
| 201 |
+
"""Get function signature while evaluating forward references"""
|
| 202 |
+
signature = inspect.signature(call)
|
| 203 |
+
globalns = getattr(call, "__globals__", {})
|
| 204 |
+
typed_params = [
|
| 205 |
+
inspect.Parameter(
|
| 206 |
+
name=param.name,
|
| 207 |
+
kind=param.kind,
|
| 208 |
+
default=param.default,
|
| 209 |
+
annotation=_get_typed_annotation(param.annotation, globalns),
|
| 210 |
+
)
|
| 211 |
+
for param in signature.parameters.values()
|
| 212 |
+
]
|
| 213 |
+
typed_signature = inspect.Signature(typed_params)
|
| 214 |
+
return typed_signature
|
src/fastmcp/utilities/types.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
from pathlib import Path
|
| 5 |
-
from typing import Optional, Union
|
| 6 |
|
| 7 |
from mcp.types import ImageContent
|
| 8 |
|
|
@@ -12,9 +11,9 @@ class Image:
|
|
| 12 |
|
| 13 |
def __init__(
|
| 14 |
self,
|
| 15 |
-
path:
|
| 16 |
-
data:
|
| 17 |
-
format:
|
| 18 |
):
|
| 19 |
if path is None and data is None:
|
| 20 |
raise ValueError("Either path or data must be provided")
|
|
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
from pathlib import Path
|
|
|
|
| 5 |
|
| 6 |
from mcp.types import ImageContent
|
| 7 |
|
|
|
|
| 11 |
|
| 12 |
def __init__(
|
| 13 |
self,
|
| 14 |
+
path: str | Path | None = None,
|
| 15 |
+
data: bytes | None = None,
|
| 16 |
+
format: str | None = None,
|
| 17 |
):
|
| 18 |
if path is None and data is None:
|
| 19 |
raise ValueError("Either path or data must be provided")
|
tests/prompts/__init__.py
ADDED
|
File without changes
|
tests/prompts/test_base.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from mcp.types import EmbeddedResource, TextResourceContents
|
| 3 |
+
from pydantic import FileUrl
|
| 4 |
+
|
| 5 |
+
from fastmcp.prompts.base import (
|
| 6 |
+
AssistantMessage,
|
| 7 |
+
Message,
|
| 8 |
+
Prompt,
|
| 9 |
+
TextContent,
|
| 10 |
+
UserMessage,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestRenderPrompt:
|
| 15 |
+
@pytest.mark.anyio
|
| 16 |
+
async def test_basic_fn(self):
|
| 17 |
+
def fn() -> str:
|
| 18 |
+
return "Hello, world!"
|
| 19 |
+
|
| 20 |
+
prompt = Prompt.from_function(fn)
|
| 21 |
+
assert await prompt.render() == [
|
| 22 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
@pytest.mark.anyio
|
| 26 |
+
async def test_async_fn(self):
|
| 27 |
+
async def fn() -> str:
|
| 28 |
+
return "Hello, world!"
|
| 29 |
+
|
| 30 |
+
prompt = Prompt.from_function(fn)
|
| 31 |
+
assert await prompt.render() == [
|
| 32 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
@pytest.mark.anyio
|
| 36 |
+
async def test_fn_with_args(self):
|
| 37 |
+
async def fn(name: str, age: int = 30) -> str:
|
| 38 |
+
return f"Hello, {name}! You're {age} years old."
|
| 39 |
+
|
| 40 |
+
prompt = Prompt.from_function(fn)
|
| 41 |
+
assert await prompt.render(arguments=dict(name="World")) == [
|
| 42 |
+
UserMessage(
|
| 43 |
+
content=TextContent(
|
| 44 |
+
type="text", text="Hello, World! You're 30 years old."
|
| 45 |
+
)
|
| 46 |
+
)
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
@pytest.mark.anyio
|
| 50 |
+
async def test_fn_with_invalid_kwargs(self):
|
| 51 |
+
async def fn(name: str, age: int = 30) -> str:
|
| 52 |
+
return f"Hello, {name}! You're {age} years old."
|
| 53 |
+
|
| 54 |
+
prompt = Prompt.from_function(fn)
|
| 55 |
+
with pytest.raises(ValueError):
|
| 56 |
+
await prompt.render(arguments=dict(age=40))
|
| 57 |
+
|
| 58 |
+
@pytest.mark.anyio
|
| 59 |
+
async def test_fn_returns_message(self):
|
| 60 |
+
async def fn() -> UserMessage:
|
| 61 |
+
return UserMessage(content="Hello, world!")
|
| 62 |
+
|
| 63 |
+
prompt = Prompt.from_function(fn)
|
| 64 |
+
assert await prompt.render() == [
|
| 65 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
@pytest.mark.anyio
|
| 69 |
+
async def test_fn_returns_assistant_message(self):
|
| 70 |
+
async def fn() -> AssistantMessage:
|
| 71 |
+
return AssistantMessage(
|
| 72 |
+
content=TextContent(type="text", text="Hello, world!")
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
prompt = Prompt.from_function(fn)
|
| 76 |
+
assert await prompt.render() == [
|
| 77 |
+
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
@pytest.mark.anyio
|
| 81 |
+
async def test_fn_returns_multiple_messages(self):
|
| 82 |
+
expected = [
|
| 83 |
+
UserMessage("Hello, world!"),
|
| 84 |
+
AssistantMessage("How can I help you today?"),
|
| 85 |
+
UserMessage("I'm looking for a restaurant in the center of town."),
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
async def fn() -> list[Message]:
|
| 89 |
+
return expected
|
| 90 |
+
|
| 91 |
+
prompt = Prompt.from_function(fn)
|
| 92 |
+
assert await prompt.render() == expected
|
| 93 |
+
|
| 94 |
+
@pytest.mark.anyio
|
| 95 |
+
async def test_fn_returns_list_of_strings(self):
|
| 96 |
+
expected = [
|
| 97 |
+
"Hello, world!",
|
| 98 |
+
"I'm looking for a restaurant in the center of town.",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
async def fn() -> list[str]:
|
| 102 |
+
return expected
|
| 103 |
+
|
| 104 |
+
prompt = Prompt.from_function(fn)
|
| 105 |
+
assert await prompt.render() == [UserMessage(t) for t in expected]
|
| 106 |
+
|
| 107 |
+
@pytest.mark.anyio
|
| 108 |
+
async def test_fn_returns_resource_content(self):
|
| 109 |
+
"""Test returning a message with resource content."""
|
| 110 |
+
|
| 111 |
+
async def fn() -> UserMessage:
|
| 112 |
+
return UserMessage(
|
| 113 |
+
content=EmbeddedResource(
|
| 114 |
+
type="resource",
|
| 115 |
+
resource=TextResourceContents(
|
| 116 |
+
uri=FileUrl("file://file.txt"),
|
| 117 |
+
text="File contents",
|
| 118 |
+
mimeType="text/plain",
|
| 119 |
+
),
|
| 120 |
+
)
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
prompt = Prompt.from_function(fn)
|
| 124 |
+
assert await prompt.render() == [
|
| 125 |
+
UserMessage(
|
| 126 |
+
content=EmbeddedResource(
|
| 127 |
+
type="resource",
|
| 128 |
+
resource=TextResourceContents(
|
| 129 |
+
uri=FileUrl("file://file.txt"),
|
| 130 |
+
text="File contents",
|
| 131 |
+
mimeType="text/plain",
|
| 132 |
+
),
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
@pytest.mark.anyio
|
| 138 |
+
async def test_fn_returns_mixed_content(self):
|
| 139 |
+
"""Test returning messages with mixed content types."""
|
| 140 |
+
|
| 141 |
+
async def fn() -> list[Message]:
|
| 142 |
+
return [
|
| 143 |
+
UserMessage(content="Please analyze this file:"),
|
| 144 |
+
UserMessage(
|
| 145 |
+
content=EmbeddedResource(
|
| 146 |
+
type="resource",
|
| 147 |
+
resource=TextResourceContents(
|
| 148 |
+
uri=FileUrl("file://file.txt"),
|
| 149 |
+
text="File contents",
|
| 150 |
+
mimeType="text/plain",
|
| 151 |
+
),
|
| 152 |
+
)
|
| 153 |
+
),
|
| 154 |
+
AssistantMessage(content="I'll help analyze that file."),
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
prompt = Prompt.from_function(fn)
|
| 158 |
+
assert await prompt.render() == [
|
| 159 |
+
UserMessage(
|
| 160 |
+
content=TextContent(type="text", text="Please analyze this file:")
|
| 161 |
+
),
|
| 162 |
+
UserMessage(
|
| 163 |
+
content=EmbeddedResource(
|
| 164 |
+
type="resource",
|
| 165 |
+
resource=TextResourceContents(
|
| 166 |
+
uri=FileUrl("file://file.txt"),
|
| 167 |
+
text="File contents",
|
| 168 |
+
mimeType="text/plain",
|
| 169 |
+
),
|
| 170 |
+
)
|
| 171 |
+
),
|
| 172 |
+
AssistantMessage(
|
| 173 |
+
content=TextContent(type="text", text="I'll help analyze that file.")
|
| 174 |
+
),
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
@pytest.mark.anyio
|
| 178 |
+
async def test_fn_returns_dict_with_resource(self):
|
| 179 |
+
"""Test returning a dict with resource content."""
|
| 180 |
+
|
| 181 |
+
async def fn() -> dict:
|
| 182 |
+
return {
|
| 183 |
+
"role": "user",
|
| 184 |
+
"content": {
|
| 185 |
+
"type": "resource",
|
| 186 |
+
"resource": {
|
| 187 |
+
"uri": FileUrl("file://file.txt"),
|
| 188 |
+
"text": "File contents",
|
| 189 |
+
"mimeType": "text/plain",
|
| 190 |
+
},
|
| 191 |
+
},
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
prompt = Prompt.from_function(fn)
|
| 195 |
+
assert await prompt.render() == [
|
| 196 |
+
UserMessage(
|
| 197 |
+
content=EmbeddedResource(
|
| 198 |
+
type="resource",
|
| 199 |
+
resource=TextResourceContents(
|
| 200 |
+
uri=FileUrl("file://file.txt"),
|
| 201 |
+
text="File contents",
|
| 202 |
+
mimeType="text/plain",
|
| 203 |
+
),
|
| 204 |
+
)
|
| 205 |
+
)
|
| 206 |
+
]
|
tests/prompts/test_prompt_manager.py
CHANGED
|
@@ -1,166 +1,283 @@
|
|
| 1 |
-
|
| 2 |
-
from mcp.server.fastmcp.prompts.base import PromptArgument
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from fastmcp.prompts.prompt_manager import PromptManager
|
| 5 |
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
return [{"role": "assistant", "content": f"Summary of: {kwargs.get('text')}"}]
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
]
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
)
|
|
|
|
| 80 |
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
name="common",
|
| 97 |
-
description="Source description",
|
| 98 |
-
arguments=None,
|
| 99 |
-
fn=source_fn,
|
| 100 |
-
)
|
| 101 |
-
source_manager._prompts["common"] = source_prompt
|
| 102 |
-
|
| 103 |
-
target_prompt = Prompt(
|
| 104 |
-
name="common",
|
| 105 |
-
description="Target description",
|
| 106 |
-
arguments=None,
|
| 107 |
-
fn=target_fn,
|
| 108 |
-
)
|
| 109 |
-
target_manager._prompts["common"] = target_prompt
|
| 110 |
-
|
| 111 |
-
# Import prompts with prefix
|
| 112 |
-
prefix = "external/"
|
| 113 |
-
target_manager.import_prompts(source_manager, prefix)
|
| 114 |
-
|
| 115 |
-
# Verify both prompts exist in target manager
|
| 116 |
-
assert "common" in target_manager._prompts
|
| 117 |
-
assert "external/common" in target_manager._prompts
|
| 118 |
-
|
| 119 |
-
# Verify the functions of both prompts
|
| 120 |
-
if hasattr(target_manager._prompts["common"], "fn") and hasattr(
|
| 121 |
-
target_manager._prompts["external/common"], "fn"
|
| 122 |
-
):
|
| 123 |
-
assert target_manager._prompts["common"].fn.__name__ == target_fn.__name__
|
| 124 |
assert (
|
| 125 |
-
target_manager._prompts["
|
|
|
|
| 126 |
)
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
| 1 |
+
import pytest
|
|
|
|
| 2 |
|
| 3 |
+
from fastmcp.prompts import Prompt
|
| 4 |
+
from fastmcp.prompts.base import PromptArgument, TextContent, UserMessage
|
| 5 |
from fastmcp.prompts.prompt_manager import PromptManager
|
| 6 |
|
| 7 |
|
| 8 |
+
class TestPromptManager:
|
| 9 |
+
def test_add_prompt(self):
|
| 10 |
+
"""Test adding a prompt to the manager."""
|
| 11 |
+
|
| 12 |
+
def fn() -> str:
|
| 13 |
+
return "Hello, world!"
|
| 14 |
+
|
| 15 |
+
manager = PromptManager()
|
| 16 |
+
prompt = Prompt.from_function(fn)
|
| 17 |
+
added = manager.add_prompt(prompt)
|
| 18 |
+
assert added == prompt
|
| 19 |
+
assert manager.get_prompt("fn") == prompt
|
| 20 |
+
|
| 21 |
+
def test_add_duplicate_prompt(self, caplog):
|
| 22 |
+
"""Test adding the same prompt twice."""
|
| 23 |
+
|
| 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)
|
| 31 |
+
assert first == second
|
| 32 |
+
assert "Prompt already exists" in caplog.text
|
| 33 |
+
|
| 34 |
+
def test_disable_warn_on_duplicate_prompts(self, caplog):
|
| 35 |
+
"""Test disabling warning on duplicate prompts."""
|
| 36 |
+
|
| 37 |
+
def fn() -> str:
|
| 38 |
+
return "Hello, world!"
|
| 39 |
+
|
| 40 |
+
manager = PromptManager(warn_on_duplicate_prompts=False)
|
| 41 |
+
prompt = Prompt.from_function(fn)
|
| 42 |
+
first = manager.add_prompt(prompt)
|
| 43 |
+
second = manager.add_prompt(prompt)
|
| 44 |
+
assert first == second
|
| 45 |
+
assert "Prompt already exists" not in caplog.text
|
| 46 |
+
|
| 47 |
+
def test_list_prompts(self):
|
| 48 |
+
"""Test listing all prompts."""
|
| 49 |
+
|
| 50 |
+
def fn1() -> str:
|
| 51 |
+
return "Hello, world!"
|
| 52 |
+
|
| 53 |
+
def fn2() -> str:
|
| 54 |
+
return "Goodbye, world!"
|
| 55 |
+
|
| 56 |
+
manager = PromptManager()
|
| 57 |
+
prompt1 = Prompt.from_function(fn1)
|
| 58 |
+
prompt2 = Prompt.from_function(fn2)
|
| 59 |
+
manager.add_prompt(prompt1)
|
| 60 |
+
manager.add_prompt(prompt2)
|
| 61 |
+
prompts = manager.list_prompts()
|
| 62 |
+
assert len(prompts) == 2
|
| 63 |
+
assert prompts == [prompt1, prompt2]
|
| 64 |
+
|
| 65 |
+
@pytest.mark.anyio
|
| 66 |
+
async def test_render_prompt(self):
|
| 67 |
+
"""Test rendering a prompt."""
|
| 68 |
+
|
| 69 |
+
def fn() -> str:
|
| 70 |
+
return "Hello, world!"
|
| 71 |
+
|
| 72 |
+
manager = PromptManager()
|
| 73 |
+
prompt = Prompt.from_function(fn)
|
| 74 |
+
manager.add_prompt(prompt)
|
| 75 |
+
messages = await manager.render_prompt("fn")
|
| 76 |
+
assert messages == [
|
| 77 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
@pytest.mark.anyio
|
| 81 |
+
async def test_render_prompt_with_args(self):
|
| 82 |
+
"""Test rendering a prompt with arguments."""
|
| 83 |
|
| 84 |
+
def fn(name: str) -> str:
|
| 85 |
+
return f"Hello, {name}!"
|
|
|
|
| 86 |
|
| 87 |
+
manager = PromptManager()
|
| 88 |
+
prompt = Prompt.from_function(fn)
|
| 89 |
+
manager.add_prompt(prompt)
|
| 90 |
+
messages = await manager.render_prompt("fn", arguments={"name": "World"})
|
| 91 |
+
assert messages == [
|
| 92 |
+
UserMessage(content=TextContent(type="text", text="Hello, World!"))
|
| 93 |
]
|
| 94 |
|
| 95 |
+
@pytest.mark.anyio
|
| 96 |
+
async def test_render_unknown_prompt(self):
|
| 97 |
+
"""Test rendering a non-existent prompt."""
|
| 98 |
+
manager = PromptManager()
|
| 99 |
+
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
|
| 100 |
+
await manager.render_prompt("unknown")
|
| 101 |
+
|
| 102 |
+
@pytest.mark.anyio
|
| 103 |
+
async def test_render_prompt_with_missing_args(self):
|
| 104 |
+
"""Test rendering a prompt with missing required arguments."""
|
| 105 |
+
|
| 106 |
+
def fn(name: str) -> str:
|
| 107 |
+
return f"Hello, {name}!"
|
| 108 |
+
|
| 109 |
+
manager = PromptManager()
|
| 110 |
+
prompt = Prompt.from_function(fn)
|
| 111 |
+
manager.add_prompt(prompt)
|
| 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):
|
| 118 |
+
"""Test importing prompts from one manager to another with a prefix."""
|
| 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=summary_fn,
|
| 141 |
+
)
|
| 142 |
+
source_manager._prompts["summary"] = summary_prompt
|
| 143 |
+
|
| 144 |
+
translate_prompt = Prompt(
|
| 145 |
+
name="translate",
|
| 146 |
+
description="Translate text to another language",
|
| 147 |
+
arguments=[
|
| 148 |
+
PromptArgument(name="text", description="Text to translate"),
|
| 149 |
+
PromptArgument(name="language", description="Target language"),
|
| 150 |
+
],
|
| 151 |
+
fn=translate_fn,
|
| 152 |
)
|
| 153 |
+
source_manager._prompts["translate"] = translate_prompt
|
| 154 |
|
| 155 |
+
# Create target manager
|
| 156 |
+
target_manager = PromptManager()
|
| 157 |
|
| 158 |
+
# Import prompts from source to target
|
| 159 |
+
prefix = "nlp/"
|
| 160 |
+
target_manager.import_prompts(source_manager, prefix)
|
| 161 |
+
|
| 162 |
+
# Verify prompts were imported with prefixes
|
| 163 |
+
assert "nlp/summary" in target_manager._prompts
|
| 164 |
+
assert "nlp/translate" in target_manager._prompts
|
| 165 |
+
|
| 166 |
+
# Verify the original prompts still exist in source manager
|
| 167 |
+
assert "summary" in source_manager._prompts
|
| 168 |
+
assert "translate" in source_manager._prompts
|
| 169 |
+
|
| 170 |
+
# Verify the imported prompts have the correct properties
|
| 171 |
+
assert target_manager._prompts["nlp/summary"].name == "summary"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."""
|
| 198 |
+
# Setup source and target managers with same prompt names
|
| 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=source_fn,
|
| 214 |
+
)
|
| 215 |
+
source_manager._prompts["common"] = source_prompt
|
| 216 |
|
| 217 |
+
target_prompt = Prompt(
|
| 218 |
+
name="common",
|
| 219 |
+
description="Target description",
|
| 220 |
+
arguments=None,
|
| 221 |
+
fn=target_fn,
|
| 222 |
+
)
|
| 223 |
+
target_manager._prompts["common"] = target_prompt
|
| 224 |
+
|
| 225 |
+
# Import prompts with prefix
|
| 226 |
+
prefix = "external/"
|
| 227 |
+
target_manager.import_prompts(source_manager, prefix)
|
| 228 |
+
|
| 229 |
+
# Verify both prompts exist in target manager
|
| 230 |
+
assert "common" in target_manager._prompts
|
| 231 |
+
assert "external/common" in target_manager._prompts
|
| 232 |
+
|
| 233 |
+
# Verify the functions of both prompts
|
| 234 |
+
if hasattr(target_manager._prompts["common"], "fn") and hasattr(
|
| 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."""
|
| 245 |
+
# Setup source manager with already prefixed prompts
|
| 246 |
+
first_manager = PromptManager()
|
| 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=analyze_fn,
|
| 261 |
+
)
|
| 262 |
+
first_manager._prompts["analyze"] = original_prompt
|
| 263 |
|
| 264 |
+
# Import to second manager with prefix
|
| 265 |
+
second_manager.import_prompts(first_manager, "text/")
|
| 266 |
|
| 267 |
+
# Import from second to third with another prefix
|
| 268 |
+
third_manager.import_prompts(second_manager, "ai/")
|
| 269 |
|
| 270 |
+
# Verify the nested prefixing
|
| 271 |
+
assert "text/analyze" in second_manager._prompts
|
| 272 |
+
assert "ai/text/analyze" in third_manager._prompts
|
| 273 |
|
| 274 |
+
# Verify the properties of the most nested prompt
|
| 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 |
+
)
|
tests/resources/__init__.py
ADDED
|
File without changes
|
tests/resources/test_file_resources.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from tempfile import NamedTemporaryFile
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import FileUrl
|
| 7 |
+
|
| 8 |
+
from fastmcp.resources import FileResource
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def temp_file():
|
| 13 |
+
"""Create a temporary file for testing.
|
| 14 |
+
|
| 15 |
+
File is automatically cleaned up after the test if it still exists.
|
| 16 |
+
"""
|
| 17 |
+
content = "test content"
|
| 18 |
+
with NamedTemporaryFile(mode="w", delete=False) as f:
|
| 19 |
+
f.write(content)
|
| 20 |
+
path = Path(f.name).resolve()
|
| 21 |
+
yield path
|
| 22 |
+
try:
|
| 23 |
+
path.unlink()
|
| 24 |
+
except FileNotFoundError:
|
| 25 |
+
pass # File was already deleted by the test
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TestFileResource:
|
| 29 |
+
"""Test FileResource functionality."""
|
| 30 |
+
|
| 31 |
+
def test_file_resource_creation(self, temp_file: Path):
|
| 32 |
+
"""Test creating a FileResource."""
|
| 33 |
+
resource = FileResource(
|
| 34 |
+
uri=FileUrl(temp_file.as_uri()),
|
| 35 |
+
name="test",
|
| 36 |
+
description="test file",
|
| 37 |
+
path=temp_file,
|
| 38 |
+
)
|
| 39 |
+
assert str(resource.uri) == temp_file.as_uri()
|
| 40 |
+
assert resource.name == "test"
|
| 41 |
+
assert resource.description == "test file"
|
| 42 |
+
assert resource.mime_type == "text/plain" # default
|
| 43 |
+
assert resource.path == temp_file
|
| 44 |
+
assert resource.is_binary is False # default
|
| 45 |
+
|
| 46 |
+
def test_file_resource_str_path_conversion(self, temp_file: Path):
|
| 47 |
+
"""Test FileResource handles string paths."""
|
| 48 |
+
resource = FileResource(
|
| 49 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 50 |
+
name="test",
|
| 51 |
+
path=Path(str(temp_file)),
|
| 52 |
+
)
|
| 53 |
+
assert isinstance(resource.path, Path)
|
| 54 |
+
assert resource.path.is_absolute()
|
| 55 |
+
|
| 56 |
+
@pytest.mark.anyio
|
| 57 |
+
async def test_read_text_file(self, temp_file: Path):
|
| 58 |
+
"""Test reading a text file."""
|
| 59 |
+
resource = FileResource(
|
| 60 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 61 |
+
name="test",
|
| 62 |
+
path=temp_file,
|
| 63 |
+
)
|
| 64 |
+
content = await resource.read()
|
| 65 |
+
assert content == "test content"
|
| 66 |
+
assert resource.mime_type == "text/plain"
|
| 67 |
+
|
| 68 |
+
@pytest.mark.anyio
|
| 69 |
+
async def test_read_binary_file(self, temp_file: Path):
|
| 70 |
+
"""Test reading a file as binary."""
|
| 71 |
+
resource = FileResource(
|
| 72 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 73 |
+
name="test",
|
| 74 |
+
path=temp_file,
|
| 75 |
+
is_binary=True,
|
| 76 |
+
)
|
| 77 |
+
content = await resource.read()
|
| 78 |
+
assert isinstance(content, bytes)
|
| 79 |
+
assert content == b"test content"
|
| 80 |
+
|
| 81 |
+
def test_relative_path_error(self):
|
| 82 |
+
"""Test error on relative path."""
|
| 83 |
+
with pytest.raises(ValueError, match="Path must be absolute"):
|
| 84 |
+
FileResource(
|
| 85 |
+
uri=FileUrl("file:///test.txt"),
|
| 86 |
+
name="test",
|
| 87 |
+
path=Path("test.txt"),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
@pytest.mark.anyio
|
| 91 |
+
async def test_missing_file_error(self, temp_file: Path):
|
| 92 |
+
"""Test error when file doesn't exist."""
|
| 93 |
+
# Create path to non-existent file
|
| 94 |
+
missing = temp_file.parent / "missing.txt"
|
| 95 |
+
resource = FileResource(
|
| 96 |
+
uri=FileUrl("file:///missing.txt"),
|
| 97 |
+
name="test",
|
| 98 |
+
path=missing,
|
| 99 |
+
)
|
| 100 |
+
with pytest.raises(ValueError, match="Error reading file"):
|
| 101 |
+
await resource.read()
|
| 102 |
+
|
| 103 |
+
@pytest.mark.skipif(
|
| 104 |
+
os.name == "nt", reason="File permissions behave differently on Windows"
|
| 105 |
+
)
|
| 106 |
+
@pytest.mark.anyio
|
| 107 |
+
async def test_permission_error(self, temp_file: Path):
|
| 108 |
+
"""Test reading a file without permissions."""
|
| 109 |
+
temp_file.chmod(0o000) # Remove all permissions
|
| 110 |
+
try:
|
| 111 |
+
resource = FileResource(
|
| 112 |
+
uri=FileUrl(temp_file.as_uri()),
|
| 113 |
+
name="test",
|
| 114 |
+
path=temp_file,
|
| 115 |
+
)
|
| 116 |
+
with pytest.raises(ValueError, match="Error reading file"):
|
| 117 |
+
await resource.read()
|
| 118 |
+
finally:
|
| 119 |
+
temp_file.chmod(0o644) # Restore permissions
|
tests/resources/test_function_resources.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from pydantic import AnyUrl, BaseModel
|
| 3 |
+
|
| 4 |
+
from fastmcp.resources import FunctionResource
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestFunctionResource:
|
| 8 |
+
"""Test FunctionResource functionality."""
|
| 9 |
+
|
| 10 |
+
def test_function_resource_creation(self):
|
| 11 |
+
"""Test creating a FunctionResource."""
|
| 12 |
+
|
| 13 |
+
def my_func() -> str:
|
| 14 |
+
return "test content"
|
| 15 |
+
|
| 16 |
+
resource = FunctionResource(
|
| 17 |
+
uri=AnyUrl("fn://test"),
|
| 18 |
+
name="test",
|
| 19 |
+
description="test function",
|
| 20 |
+
fn=my_func,
|
| 21 |
+
)
|
| 22 |
+
assert str(resource.uri) == "fn://test"
|
| 23 |
+
assert resource.name == "test"
|
| 24 |
+
assert resource.description == "test function"
|
| 25 |
+
assert resource.mime_type == "text/plain" # default
|
| 26 |
+
assert resource.fn == my_func
|
| 27 |
+
|
| 28 |
+
@pytest.mark.anyio
|
| 29 |
+
async def test_read_text(self):
|
| 30 |
+
"""Test reading text from a FunctionResource."""
|
| 31 |
+
|
| 32 |
+
def get_data() -> str:
|
| 33 |
+
return "Hello, world!"
|
| 34 |
+
|
| 35 |
+
resource = FunctionResource(
|
| 36 |
+
uri=AnyUrl("function://test"),
|
| 37 |
+
name="test",
|
| 38 |
+
fn=get_data,
|
| 39 |
+
)
|
| 40 |
+
content = await resource.read()
|
| 41 |
+
assert content == "Hello, world!"
|
| 42 |
+
assert resource.mime_type == "text/plain"
|
| 43 |
+
|
| 44 |
+
@pytest.mark.anyio
|
| 45 |
+
async def test_read_binary(self):
|
| 46 |
+
"""Test reading binary data from a FunctionResource."""
|
| 47 |
+
|
| 48 |
+
def get_data() -> bytes:
|
| 49 |
+
return b"Hello, world!"
|
| 50 |
+
|
| 51 |
+
resource = FunctionResource(
|
| 52 |
+
uri=AnyUrl("function://test"),
|
| 53 |
+
name="test",
|
| 54 |
+
fn=get_data,
|
| 55 |
+
)
|
| 56 |
+
content = await resource.read()
|
| 57 |
+
assert content == b"Hello, world!"
|
| 58 |
+
|
| 59 |
+
@pytest.mark.anyio
|
| 60 |
+
async def test_json_conversion(self):
|
| 61 |
+
"""Test automatic JSON conversion of non-string results."""
|
| 62 |
+
|
| 63 |
+
def get_data() -> dict:
|
| 64 |
+
return {"key": "value"}
|
| 65 |
+
|
| 66 |
+
resource = FunctionResource(
|
| 67 |
+
uri=AnyUrl("function://test"),
|
| 68 |
+
name="test",
|
| 69 |
+
fn=get_data,
|
| 70 |
+
)
|
| 71 |
+
content = await resource.read()
|
| 72 |
+
assert isinstance(content, str)
|
| 73 |
+
assert '"key": "value"' in content
|
| 74 |
+
|
| 75 |
+
@pytest.mark.anyio
|
| 76 |
+
async def test_error_handling(self):
|
| 77 |
+
"""Test error handling in FunctionResource."""
|
| 78 |
+
|
| 79 |
+
def failing_func() -> str:
|
| 80 |
+
raise ValueError("Test error")
|
| 81 |
+
|
| 82 |
+
resource = FunctionResource(
|
| 83 |
+
uri=AnyUrl("function://test"),
|
| 84 |
+
name="test",
|
| 85 |
+
fn=failing_func,
|
| 86 |
+
)
|
| 87 |
+
with pytest.raises(ValueError, match="Error reading resource function://test"):
|
| 88 |
+
await resource.read()
|
| 89 |
+
|
| 90 |
+
@pytest.mark.anyio
|
| 91 |
+
async def test_basemodel_conversion(self):
|
| 92 |
+
"""Test handling of BaseModel types."""
|
| 93 |
+
|
| 94 |
+
class MyModel(BaseModel):
|
| 95 |
+
name: str
|
| 96 |
+
|
| 97 |
+
resource = FunctionResource(
|
| 98 |
+
uri=AnyUrl("function://test"),
|
| 99 |
+
name="test",
|
| 100 |
+
fn=lambda: MyModel(name="test"),
|
| 101 |
+
)
|
| 102 |
+
content = await resource.read()
|
| 103 |
+
assert content == '{"name": "test"}'
|
| 104 |
+
|
| 105 |
+
@pytest.mark.anyio
|
| 106 |
+
async def test_custom_type_conversion(self):
|
| 107 |
+
"""Test handling of custom types."""
|
| 108 |
+
|
| 109 |
+
class CustomData:
|
| 110 |
+
def __str__(self) -> str:
|
| 111 |
+
return "custom data"
|
| 112 |
+
|
| 113 |
+
def get_data() -> CustomData:
|
| 114 |
+
return CustomData()
|
| 115 |
+
|
| 116 |
+
resource = FunctionResource(
|
| 117 |
+
uri=AnyUrl("function://test"),
|
| 118 |
+
name="test",
|
| 119 |
+
fn=get_data,
|
| 120 |
+
)
|
| 121 |
+
content = await resource.read()
|
| 122 |
+
assert isinstance(content, str)
|
| 123 |
+
|
| 124 |
+
@pytest.mark.anyio
|
| 125 |
+
async def test_async_read_text(self):
|
| 126 |
+
"""Test reading text from async FunctionResource."""
|
| 127 |
+
|
| 128 |
+
async def get_data() -> str:
|
| 129 |
+
return "Hello, world!"
|
| 130 |
+
|
| 131 |
+
resource = FunctionResource(
|
| 132 |
+
uri=AnyUrl("function://test"),
|
| 133 |
+
name="test",
|
| 134 |
+
fn=get_data,
|
| 135 |
+
)
|
| 136 |
+
content = await resource.read()
|
| 137 |
+
assert content == "Hello, world!"
|
| 138 |
+
assert resource.mime_type == "text/plain"
|
tests/resources/test_resource_manager.py
CHANGED
|
@@ -1,221 +1,363 @@
|
|
| 1 |
-
from
|
| 2 |
-
from
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
=
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
async def
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
=
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from tempfile import NamedTemporaryFile
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from pydantic import AnyUrl, FileUrl
|
| 6 |
+
|
| 7 |
+
from fastmcp.resources import (
|
| 8 |
+
FileResource,
|
| 9 |
+
FunctionResource,
|
| 10 |
+
ResourceManager,
|
| 11 |
+
ResourceTemplate,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def temp_file():
|
| 17 |
+
"""Create a temporary file for testing.
|
| 18 |
+
|
| 19 |
+
File is automatically cleaned up after the test if it still exists.
|
| 20 |
+
"""
|
| 21 |
+
content = "test content"
|
| 22 |
+
with NamedTemporaryFile(mode="w", delete=False) as f:
|
| 23 |
+
f.write(content)
|
| 24 |
+
path = Path(f.name).resolve()
|
| 25 |
+
yield path
|
| 26 |
+
try:
|
| 27 |
+
path.unlink()
|
| 28 |
+
except FileNotFoundError:
|
| 29 |
+
pass # File was already deleted by the test
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TestResourceManager:
|
| 33 |
+
"""Test ResourceManager functionality."""
|
| 34 |
+
|
| 35 |
+
def test_add_resource(self, temp_file: Path):
|
| 36 |
+
"""Test adding a resource."""
|
| 37 |
+
manager = ResourceManager()
|
| 38 |
+
resource = FileResource(
|
| 39 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 40 |
+
name="test",
|
| 41 |
+
path=temp_file,
|
| 42 |
+
)
|
| 43 |
+
added = manager.add_resource(resource)
|
| 44 |
+
assert added == resource
|
| 45 |
+
assert manager.list_resources() == [resource]
|
| 46 |
+
|
| 47 |
+
def test_add_duplicate_resource(self, temp_file: Path):
|
| 48 |
+
"""Test adding the same resource twice."""
|
| 49 |
+
manager = ResourceManager()
|
| 50 |
+
resource = FileResource(
|
| 51 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 52 |
+
name="test",
|
| 53 |
+
path=temp_file,
|
| 54 |
+
)
|
| 55 |
+
first = manager.add_resource(resource)
|
| 56 |
+
second = manager.add_resource(resource)
|
| 57 |
+
assert first == second
|
| 58 |
+
assert manager.list_resources() == [resource]
|
| 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",
|
| 66 |
+
path=temp_file,
|
| 67 |
+
)
|
| 68 |
+
manager.add_resource(resource)
|
| 69 |
+
manager.add_resource(resource)
|
| 70 |
+
assert "Resource already exists" in caplog.text
|
| 71 |
+
|
| 72 |
+
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 73 |
+
"""Test disabling warning on duplicate resources."""
|
| 74 |
+
manager = ResourceManager(warn_on_duplicate_resources=False)
|
| 75 |
+
resource = FileResource(
|
| 76 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 77 |
+
name="test",
|
| 78 |
+
path=temp_file,
|
| 79 |
+
)
|
| 80 |
+
manager.add_resource(resource)
|
| 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."""
|
| 87 |
+
manager = ResourceManager()
|
| 88 |
+
resource = FileResource(
|
| 89 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 90 |
+
name="test",
|
| 91 |
+
path=temp_file,
|
| 92 |
+
)
|
| 93 |
+
manager.add_resource(resource)
|
| 94 |
+
retrieved = await manager.get_resource(resource.uri)
|
| 95 |
+
assert retrieved == resource
|
| 96 |
+
|
| 97 |
+
@pytest.mark.anyio
|
| 98 |
+
async def test_get_resource_from_template(self):
|
| 99 |
+
"""Test getting a resource through a template."""
|
| 100 |
+
manager = ResourceManager()
|
| 101 |
+
|
| 102 |
+
def greet(name: str) -> str:
|
| 103 |
+
return f"Hello, {name}!"
|
| 104 |
+
|
| 105 |
+
template = ResourceTemplate.from_function(
|
| 106 |
+
fn=greet,
|
| 107 |
+
uri_template="greet://{name}",
|
| 108 |
+
name="greeter",
|
| 109 |
+
)
|
| 110 |
+
manager._templates[template.uri_template] = template
|
| 111 |
+
|
| 112 |
+
resource = await manager.get_resource(AnyUrl("greet://world"))
|
| 113 |
+
assert isinstance(resource, FunctionResource)
|
| 114 |
+
content = await resource.read()
|
| 115 |
+
assert content == "Hello, world!"
|
| 116 |
+
|
| 117 |
+
@pytest.mark.anyio
|
| 118 |
+
async def test_get_unknown_resource(self):
|
| 119 |
+
"""Test getting a non-existent resource."""
|
| 120 |
+
manager = ResourceManager()
|
| 121 |
+
with pytest.raises(ValueError, match="Unknown resource"):
|
| 122 |
+
await manager.get_resource(AnyUrl("unknown://test"))
|
| 123 |
+
|
| 124 |
+
def test_list_resources(self, temp_file: Path):
|
| 125 |
+
"""Test listing all resources."""
|
| 126 |
+
manager = ResourceManager()
|
| 127 |
+
resource1 = FileResource(
|
| 128 |
+
uri=FileUrl(f"file://{temp_file}"),
|
| 129 |
+
name="test1",
|
| 130 |
+
path=temp_file,
|
| 131 |
+
)
|
| 132 |
+
resource2 = FileResource(
|
| 133 |
+
uri=FileUrl(f"file://{temp_file}2"),
|
| 134 |
+
name="test2",
|
| 135 |
+
path=temp_file,
|
| 136 |
+
)
|
| 137 |
+
manager.add_resource(resource1)
|
| 138 |
+
manager.add_resource(resource2)
|
| 139 |
+
resources = manager.list_resources()
|
| 140 |
+
assert len(resources) == 2
|
| 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."""
|
| 147 |
+
# Setup source manager with resources
|
| 148 |
+
source_manager = ResourceManager()
|
| 149 |
+
|
| 150 |
+
# Create mock resource functions
|
| 151 |
+
async def weather_fn():
|
| 152 |
+
return "Weather data"
|
| 153 |
+
|
| 154 |
+
async def traffic_fn():
|
| 155 |
+
return "Traffic data"
|
| 156 |
+
|
| 157 |
+
# Add resources to source manager
|
| 158 |
+
weather_resource = FunctionResource(
|
| 159 |
+
uri=AnyUrl("weather://forecast"),
|
| 160 |
+
name="weather_forecast",
|
| 161 |
+
description="Get weather forecast",
|
| 162 |
+
mime_type="application/json",
|
| 163 |
+
fn=weather_fn,
|
| 164 |
+
)
|
| 165 |
+
source_manager._resources["weather://forecast"] = weather_resource
|
| 166 |
+
|
| 167 |
+
traffic_resource = FunctionResource(
|
| 168 |
+
uri=AnyUrl("traffic://status"),
|
| 169 |
+
name="traffic_status",
|
| 170 |
+
description="Get traffic status",
|
| 171 |
+
mime_type="application/json",
|
| 172 |
+
fn=traffic_fn,
|
| 173 |
+
)
|
| 174 |
+
source_manager._resources["traffic://status"] = traffic_resource
|
| 175 |
+
|
| 176 |
+
# Create target manager
|
| 177 |
+
target_manager = ResourceManager()
|
| 178 |
+
|
| 179 |
+
# Import resources from source to target
|
| 180 |
+
prefix = "data+"
|
| 181 |
+
target_manager.import_resources(source_manager, prefix)
|
| 182 |
+
|
| 183 |
+
# Verify resources were imported with prefixes
|
| 184 |
+
assert "data+weather://forecast" in target_manager._resources
|
| 185 |
+
assert "data+traffic://status" in target_manager._resources
|
| 186 |
+
|
| 187 |
+
# Verify the original resources still exist in source manager
|
| 188 |
+
assert "weather://forecast" in source_manager._resources
|
| 189 |
+
assert "traffic://status" in source_manager._resources
|
| 190 |
+
|
| 191 |
+
# Verify the imported resources have the correct properties
|
| 192 |
+
assert (
|
| 193 |
+
target_manager._resources["data+weather://forecast"].name
|
| 194 |
+
== "weather_forecast"
|
| 195 |
+
)
|
| 196 |
+
assert (
|
| 197 |
+
target_manager._resources["data+weather://forecast"].description
|
| 198 |
+
== "Get weather forecast"
|
| 199 |
+
)
|
| 200 |
+
assert (
|
| 201 |
+
target_manager._resources["data+weather://forecast"].mime_type
|
| 202 |
+
== "application/json"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
assert (
|
| 206 |
+
target_manager._resources["data+traffic://status"].name == "traffic_status"
|
| 207 |
+
)
|
| 208 |
+
assert (
|
| 209 |
+
target_manager._resources["data+traffic://status"].description
|
| 210 |
+
== "Get traffic status"
|
| 211 |
+
)
|
| 212 |
+
assert (
|
| 213 |
+
target_manager._resources["data+traffic://status"].mime_type
|
| 214 |
+
== "application/json"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Since we're dealing with FunctionResource type, we can safely check function attributes
|
| 218 |
+
assert isinstance(
|
| 219 |
+
target_manager._resources["data+weather://forecast"], FunctionResource
|
| 220 |
+
)
|
| 221 |
+
assert isinstance(
|
| 222 |
+
target_manager._resources["data+traffic://status"], FunctionResource
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
weather_resource = target_manager._resources["data+weather://forecast"]
|
| 226 |
+
traffic_resource = target_manager._resources["data+traffic://status"]
|
| 227 |
+
|
| 228 |
+
if hasattr(weather_resource, "fn") and hasattr(traffic_resource, "fn"):
|
| 229 |
+
assert weather_resource.fn.__name__ == weather_fn.__name__
|
| 230 |
+
assert traffic_resource.fn.__name__ == traffic_fn.__name__
|
| 231 |
+
|
| 232 |
+
def test_import_templates(self):
|
| 233 |
+
"""Test importing resource templates from one manager to another with a prefix."""
|
| 234 |
+
# Setup source manager with templates
|
| 235 |
+
source_manager = ResourceManager()
|
| 236 |
+
|
| 237 |
+
# Create mock template functions
|
| 238 |
+
async def user_fn(**params):
|
| 239 |
+
return f"User data for id {params.get('id')}"
|
| 240 |
+
|
| 241 |
+
async def product_fn(**params):
|
| 242 |
+
return f"Product data for id {params.get('id')}"
|
| 243 |
+
|
| 244 |
+
# Add templates to source manager
|
| 245 |
+
user_template = ResourceTemplate(
|
| 246 |
+
uri_template="api://users/{id}",
|
| 247 |
+
name="user_template",
|
| 248 |
+
description="Get user by ID",
|
| 249 |
+
mime_type="application/json",
|
| 250 |
+
fn=user_fn,
|
| 251 |
+
parameters={"id": {"type": "string", "description": "User ID"}},
|
| 252 |
+
)
|
| 253 |
+
source_manager._templates["api://users/{id}"] = user_template
|
| 254 |
+
|
| 255 |
+
product_template = ResourceTemplate(
|
| 256 |
+
uri_template="api://products/{id}",
|
| 257 |
+
name="product_template",
|
| 258 |
+
description="Get product by ID",
|
| 259 |
+
mime_type="application/json",
|
| 260 |
+
fn=product_fn,
|
| 261 |
+
parameters={"id": {"type": "string", "description": "Product ID"}},
|
| 262 |
+
)
|
| 263 |
+
source_manager._templates["api://products/{id}"] = product_template
|
| 264 |
+
|
| 265 |
+
# Create target manager
|
| 266 |
+
target_manager = ResourceManager()
|
| 267 |
+
|
| 268 |
+
# Import templates from source to target
|
| 269 |
+
prefix = "shop+"
|
| 270 |
+
target_manager.import_templates(source_manager, prefix)
|
| 271 |
+
|
| 272 |
+
# Verify templates were imported with prefixes
|
| 273 |
+
assert "shop+api://users/{id}" in target_manager._templates
|
| 274 |
+
assert "shop+api://products/{id}" in target_manager._templates
|
| 275 |
+
|
| 276 |
+
# Verify the original templates still exist in source manager
|
| 277 |
+
assert "api://users/{id}" in source_manager._templates
|
| 278 |
+
assert "api://products/{id}" in source_manager._templates
|
| 279 |
+
|
| 280 |
+
# Verify the imported templates have the correct properties
|
| 281 |
+
assert (
|
| 282 |
+
target_manager._templates["shop+api://users/{id}"].name == "user_template"
|
| 283 |
+
)
|
| 284 |
+
assert (
|
| 285 |
+
target_manager._templates["shop+api://users/{id}"].description
|
| 286 |
+
== "Get user by ID"
|
| 287 |
+
)
|
| 288 |
+
assert (
|
| 289 |
+
target_manager._templates["shop+api://users/{id}"].mime_type
|
| 290 |
+
== "application/json"
|
| 291 |
+
)
|
| 292 |
+
assert target_manager._templates["shop+api://users/{id}"].parameters == {
|
| 293 |
+
"id": {"type": "string", "description": "User ID"}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
assert (
|
| 297 |
+
target_manager._templates["shop+api://products/{id}"].name
|
| 298 |
+
== "product_template"
|
| 299 |
+
)
|
| 300 |
+
assert (
|
| 301 |
+
target_manager._templates["shop+api://products/{id}"].description
|
| 302 |
+
== "Get product by ID"
|
| 303 |
+
)
|
| 304 |
+
assert (
|
| 305 |
+
target_manager._templates["shop+api://products/{id}"].mime_type
|
| 306 |
+
== "application/json"
|
| 307 |
+
)
|
| 308 |
+
assert target_manager._templates["shop+api://products/{id}"].parameters == {
|
| 309 |
+
"id": {"type": "string", "description": "Product ID"}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
# Verify the template functions were properly copied (only if the fn attribute exists)
|
| 313 |
+
user_template = target_manager._templates["shop+api://users/{id}"]
|
| 314 |
+
product_template = target_manager._templates["shop+api://products/{id}"]
|
| 315 |
+
|
| 316 |
+
if hasattr(user_template, "fn") and hasattr(product_template, "fn"):
|
| 317 |
+
assert user_template.fn.__name__ == user_fn.__name__
|
| 318 |
+
assert product_template.fn.__name__ == product_fn.__name__
|
| 319 |
+
|
| 320 |
+
def test_import_multiple_resource_types(self):
|
| 321 |
+
"""Test importing both resources and templates with the same prefix."""
|
| 322 |
+
# Setup source manager with both resources and templates
|
| 323 |
+
source_manager = ResourceManager()
|
| 324 |
+
|
| 325 |
+
# Create mock functions
|
| 326 |
+
async def resource_fn():
|
| 327 |
+
return "Resource data"
|
| 328 |
+
|
| 329 |
+
async def template_fn(**params):
|
| 330 |
+
return f"Template data for id {params.get('id')}"
|
| 331 |
+
|
| 332 |
+
# Add a resource to source manager
|
| 333 |
+
resource = FunctionResource(
|
| 334 |
+
uri=AnyUrl("data://resource"),
|
| 335 |
+
name="test_resource",
|
| 336 |
+
description="Test resource",
|
| 337 |
+
mime_type="application/json",
|
| 338 |
+
fn=resource_fn,
|
| 339 |
+
)
|
| 340 |
+
source_manager._resources["data://resource"] = resource
|
| 341 |
+
|
| 342 |
+
# Add a template to source manager
|
| 343 |
+
template = ResourceTemplate(
|
| 344 |
+
uri_template="data://template/{id}",
|
| 345 |
+
name="test_template",
|
| 346 |
+
description="Test template",
|
| 347 |
+
mime_type="application/json",
|
| 348 |
+
fn=template_fn,
|
| 349 |
+
parameters={"id": {"type": "string", "description": "ID parameter"}},
|
| 350 |
+
)
|
| 351 |
+
source_manager._templates["data://template/{id}"] = template
|
| 352 |
+
|
| 353 |
+
# Create target manager
|
| 354 |
+
target_manager = ResourceManager()
|
| 355 |
+
|
| 356 |
+
# Import both resources and templates
|
| 357 |
+
prefix = "test+"
|
| 358 |
+
target_manager.import_resources(source_manager, prefix)
|
| 359 |
+
target_manager.import_templates(source_manager, prefix)
|
| 360 |
+
|
| 361 |
+
# Verify both resource types were imported with prefixes
|
| 362 |
+
assert "test+data://resource" in target_manager._resources
|
| 363 |
+
assert "test+data://template/{id}" in target_manager._templates
|
tests/resources/test_resource_template.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
from fastmcp.resources import FunctionResource, ResourceTemplate
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestResourceTemplate:
|
| 10 |
+
"""Test ResourceTemplate functionality."""
|
| 11 |
+
|
| 12 |
+
def test_template_creation(self):
|
| 13 |
+
"""Test creating a template from a function."""
|
| 14 |
+
|
| 15 |
+
def my_func(key: str, value: int) -> dict:
|
| 16 |
+
return {"key": key, "value": value}
|
| 17 |
+
|
| 18 |
+
template = ResourceTemplate.from_function(
|
| 19 |
+
fn=my_func,
|
| 20 |
+
uri_template="test://{key}/{value}",
|
| 21 |
+
name="test",
|
| 22 |
+
)
|
| 23 |
+
assert template.uri_template == "test://{key}/{value}"
|
| 24 |
+
assert template.name == "test"
|
| 25 |
+
assert template.mime_type == "text/plain" # default
|
| 26 |
+
test_input = {"key": "test", "value": 42}
|
| 27 |
+
assert template.fn(**test_input) == my_func(**test_input)
|
| 28 |
+
|
| 29 |
+
def test_template_matches(self):
|
| 30 |
+
"""Test matching URIs against a template."""
|
| 31 |
+
|
| 32 |
+
def my_func(key: str, value: int) -> dict:
|
| 33 |
+
return {"key": key, "value": value}
|
| 34 |
+
|
| 35 |
+
template = ResourceTemplate.from_function(
|
| 36 |
+
fn=my_func,
|
| 37 |
+
uri_template="test://{key}/{value}",
|
| 38 |
+
name="test",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Valid match
|
| 42 |
+
params = template.matches("test://foo/123")
|
| 43 |
+
assert params == {"key": "foo", "value": "123"}
|
| 44 |
+
|
| 45 |
+
# No match
|
| 46 |
+
assert template.matches("test://foo") is None
|
| 47 |
+
assert template.matches("other://foo/123") is None
|
| 48 |
+
|
| 49 |
+
@pytest.mark.anyio
|
| 50 |
+
async def test_create_resource(self):
|
| 51 |
+
"""Test creating a resource from a template."""
|
| 52 |
+
|
| 53 |
+
def my_func(key: str, value: int) -> dict:
|
| 54 |
+
return {"key": key, "value": value}
|
| 55 |
+
|
| 56 |
+
template = ResourceTemplate.from_function(
|
| 57 |
+
fn=my_func,
|
| 58 |
+
uri_template="test://{key}/{value}",
|
| 59 |
+
name="test",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
resource = await template.create_resource(
|
| 63 |
+
"test://foo/123",
|
| 64 |
+
{"key": "foo", "value": 123},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
assert isinstance(resource, FunctionResource)
|
| 68 |
+
content = await resource.read()
|
| 69 |
+
assert isinstance(content, str)
|
| 70 |
+
data = json.loads(content)
|
| 71 |
+
assert data == {"key": "foo", "value": 123}
|
| 72 |
+
|
| 73 |
+
@pytest.mark.anyio
|
| 74 |
+
async def test_template_error(self):
|
| 75 |
+
"""Test error handling in template resource creation."""
|
| 76 |
+
|
| 77 |
+
def failing_func(x: str) -> str:
|
| 78 |
+
raise ValueError("Test error")
|
| 79 |
+
|
| 80 |
+
template = ResourceTemplate.from_function(
|
| 81 |
+
fn=failing_func,
|
| 82 |
+
uri_template="fail://{x}",
|
| 83 |
+
name="fail",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
with pytest.raises(ValueError, match="Error creating resource from template"):
|
| 87 |
+
await template.create_resource("fail://test", {"x": "test"})
|
| 88 |
+
|
| 89 |
+
@pytest.mark.anyio
|
| 90 |
+
async def test_async_text_resource(self):
|
| 91 |
+
"""Test creating a text resource from async function."""
|
| 92 |
+
|
| 93 |
+
async def greet(name: str) -> str:
|
| 94 |
+
return f"Hello, {name}!"
|
| 95 |
+
|
| 96 |
+
template = ResourceTemplate.from_function(
|
| 97 |
+
fn=greet,
|
| 98 |
+
uri_template="greet://{name}",
|
| 99 |
+
name="greeter",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
resource = await template.create_resource(
|
| 103 |
+
"greet://world",
|
| 104 |
+
{"name": "world"},
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
assert isinstance(resource, FunctionResource)
|
| 108 |
+
content = await resource.read()
|
| 109 |
+
assert content == "Hello, world!"
|
| 110 |
+
|
| 111 |
+
@pytest.mark.anyio
|
| 112 |
+
async def test_async_binary_resource(self):
|
| 113 |
+
"""Test creating a binary resource from async function."""
|
| 114 |
+
|
| 115 |
+
async def get_bytes(value: str) -> bytes:
|
| 116 |
+
return value.encode()
|
| 117 |
+
|
| 118 |
+
template = ResourceTemplate.from_function(
|
| 119 |
+
fn=get_bytes,
|
| 120 |
+
uri_template="bytes://{value}",
|
| 121 |
+
name="bytes",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
resource = await template.create_resource(
|
| 125 |
+
"bytes://test",
|
| 126 |
+
{"value": "test"},
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
assert isinstance(resource, FunctionResource)
|
| 130 |
+
content = await resource.read()
|
| 131 |
+
assert content == b"test"
|
| 132 |
+
|
| 133 |
+
@pytest.mark.anyio
|
| 134 |
+
async def test_basemodel_conversion(self):
|
| 135 |
+
"""Test handling of BaseModel types."""
|
| 136 |
+
|
| 137 |
+
class MyModel(BaseModel):
|
| 138 |
+
key: str
|
| 139 |
+
value: int
|
| 140 |
+
|
| 141 |
+
def get_data(key: str, value: int) -> MyModel:
|
| 142 |
+
return MyModel(key=key, value=value)
|
| 143 |
+
|
| 144 |
+
template = ResourceTemplate.from_function(
|
| 145 |
+
fn=get_data,
|
| 146 |
+
uri_template="test://{key}/{value}",
|
| 147 |
+
name="test",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
resource = await template.create_resource(
|
| 151 |
+
"test://foo/123",
|
| 152 |
+
{"key": "foo", "value": 123},
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
assert isinstance(resource, FunctionResource)
|
| 156 |
+
content = await resource.read()
|
| 157 |
+
assert isinstance(content, str)
|
| 158 |
+
data = json.loads(content)
|
| 159 |
+
assert data == {"key": "foo", "value": 123}
|
| 160 |
+
|
| 161 |
+
@pytest.mark.anyio
|
| 162 |
+
async def test_custom_type_conversion(self):
|
| 163 |
+
"""Test handling of custom types."""
|
| 164 |
+
|
| 165 |
+
class CustomData:
|
| 166 |
+
def __init__(self, value: str):
|
| 167 |
+
self.value = value
|
| 168 |
+
|
| 169 |
+
def __str__(self) -> str:
|
| 170 |
+
return self.value
|
| 171 |
+
|
| 172 |
+
def get_data(value: str) -> CustomData:
|
| 173 |
+
return CustomData(value)
|
| 174 |
+
|
| 175 |
+
template = ResourceTemplate.from_function(
|
| 176 |
+
fn=get_data,
|
| 177 |
+
uri_template="test://{value}",
|
| 178 |
+
name="test",
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
resource = await template.create_resource(
|
| 182 |
+
"test://hello",
|
| 183 |
+
{"value": "hello"},
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
assert isinstance(resource, FunctionResource)
|
| 187 |
+
content = await resource.read()
|
| 188 |
+
assert content == "hello"
|
tests/resources/test_resources.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from pydantic import AnyUrl
|
| 3 |
+
|
| 4 |
+
from fastmcp.resources import FunctionResource, Resource
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestResourceValidation:
|
| 8 |
+
"""Test base Resource validation."""
|
| 9 |
+
|
| 10 |
+
def test_resource_uri_validation(self):
|
| 11 |
+
"""Test URI validation."""
|
| 12 |
+
|
| 13 |
+
def dummy_func() -> str:
|
| 14 |
+
return "data"
|
| 15 |
+
|
| 16 |
+
# Valid URI
|
| 17 |
+
resource = FunctionResource(
|
| 18 |
+
uri=AnyUrl("http://example.com/data"),
|
| 19 |
+
name="test",
|
| 20 |
+
fn=dummy_func,
|
| 21 |
+
)
|
| 22 |
+
assert str(resource.uri) == "http://example.com/data"
|
| 23 |
+
|
| 24 |
+
# Missing protocol
|
| 25 |
+
with pytest.raises(ValueError, match="Input should be a valid URL"):
|
| 26 |
+
FunctionResource(
|
| 27 |
+
uri=AnyUrl("invalid"),
|
| 28 |
+
name="test",
|
| 29 |
+
fn=dummy_func,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Missing host
|
| 33 |
+
with pytest.raises(ValueError, match="Input should be a valid URL"):
|
| 34 |
+
FunctionResource(
|
| 35 |
+
uri=AnyUrl("http://"),
|
| 36 |
+
name="test",
|
| 37 |
+
fn=dummy_func,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def test_resource_name_from_uri(self):
|
| 41 |
+
"""Test name is extracted from URI if not provided."""
|
| 42 |
+
|
| 43 |
+
def dummy_func() -> str:
|
| 44 |
+
return "data"
|
| 45 |
+
|
| 46 |
+
resource = FunctionResource(
|
| 47 |
+
uri=AnyUrl("resource://my-resource"),
|
| 48 |
+
fn=dummy_func,
|
| 49 |
+
)
|
| 50 |
+
assert resource.name == "resource://my-resource"
|
| 51 |
+
|
| 52 |
+
def test_resource_name_validation(self):
|
| 53 |
+
"""Test name validation."""
|
| 54 |
+
|
| 55 |
+
def dummy_func() -> str:
|
| 56 |
+
return "data"
|
| 57 |
+
|
| 58 |
+
# Must provide either name or URI
|
| 59 |
+
with pytest.raises(ValueError, match="Either name or uri must be provided"):
|
| 60 |
+
FunctionResource(
|
| 61 |
+
fn=dummy_func,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Explicit name takes precedence over URI
|
| 65 |
+
resource = FunctionResource(
|
| 66 |
+
uri=AnyUrl("resource://uri-name"),
|
| 67 |
+
name="explicit-name",
|
| 68 |
+
fn=dummy_func,
|
| 69 |
+
)
|
| 70 |
+
assert resource.name == "explicit-name"
|
| 71 |
+
|
| 72 |
+
def test_resource_mime_type(self):
|
| 73 |
+
"""Test mime type handling."""
|
| 74 |
+
|
| 75 |
+
def dummy_func() -> str:
|
| 76 |
+
return "data"
|
| 77 |
+
|
| 78 |
+
# Default mime type
|
| 79 |
+
resource = FunctionResource(
|
| 80 |
+
uri=AnyUrl("resource://test"),
|
| 81 |
+
fn=dummy_func,
|
| 82 |
+
)
|
| 83 |
+
assert resource.mime_type == "text/plain"
|
| 84 |
+
|
| 85 |
+
# Custom mime type
|
| 86 |
+
resource = FunctionResource(
|
| 87 |
+
uri=AnyUrl("resource://test"),
|
| 88 |
+
fn=dummy_func,
|
| 89 |
+
mime_type="application/json",
|
| 90 |
+
)
|
| 91 |
+
assert resource.mime_type == "application/json"
|
| 92 |
+
|
| 93 |
+
@pytest.mark.anyio
|
| 94 |
+
async def test_resource_read_abstract(self):
|
| 95 |
+
"""Test that Resource.read() is abstract."""
|
| 96 |
+
|
| 97 |
+
class ConcreteResource(Resource):
|
| 98 |
+
pass
|
| 99 |
+
|
| 100 |
+
with pytest.raises(TypeError, match="abstract method"):
|
| 101 |
+
ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
|
tests/server/__init__.py
ADDED
|
File without changes
|
tests/server/test_file_server.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from fastmcp import FastMCP
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture()
|
| 10 |
+
def test_dir(tmp_path_factory) -> Path:
|
| 11 |
+
"""Create a temporary directory with test files."""
|
| 12 |
+
tmp = tmp_path_factory.mktemp("test_files")
|
| 13 |
+
|
| 14 |
+
# Create test files
|
| 15 |
+
(tmp / "example.py").write_text("print('hello world')")
|
| 16 |
+
(tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
|
| 17 |
+
(tmp / "config.json").write_text('{"test": true}')
|
| 18 |
+
|
| 19 |
+
return tmp
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
def mcp() -> FastMCP:
|
| 24 |
+
mcp = FastMCP()
|
| 25 |
+
|
| 26 |
+
return mcp
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture(autouse=True)
|
| 30 |
+
def resources(mcp: FastMCP, test_dir: Path) -> FastMCP:
|
| 31 |
+
@mcp.resource("dir://test_dir")
|
| 32 |
+
def list_test_dir() -> list[str]:
|
| 33 |
+
"""List the files in the test directory"""
|
| 34 |
+
return [str(f) for f in test_dir.iterdir()]
|
| 35 |
+
|
| 36 |
+
@mcp.resource("file://test_dir/example.py")
|
| 37 |
+
def read_example_py() -> str:
|
| 38 |
+
"""Read the example.py file"""
|
| 39 |
+
try:
|
| 40 |
+
return (test_dir / "example.py").read_text()
|
| 41 |
+
except FileNotFoundError:
|
| 42 |
+
return "File not found"
|
| 43 |
+
|
| 44 |
+
@mcp.resource("file://test_dir/readme.md")
|
| 45 |
+
def read_readme_md() -> str:
|
| 46 |
+
"""Read the readme.md file"""
|
| 47 |
+
try:
|
| 48 |
+
return (test_dir / "readme.md").read_text()
|
| 49 |
+
except FileNotFoundError:
|
| 50 |
+
return "File not found"
|
| 51 |
+
|
| 52 |
+
@mcp.resource("file://test_dir/config.json")
|
| 53 |
+
def read_config_json() -> str:
|
| 54 |
+
"""Read the config.json file"""
|
| 55 |
+
try:
|
| 56 |
+
return (test_dir / "config.json").read_text()
|
| 57 |
+
except FileNotFoundError:
|
| 58 |
+
return "File not found"
|
| 59 |
+
|
| 60 |
+
return mcp
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.fixture(autouse=True)
|
| 64 |
+
def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
|
| 65 |
+
@mcp.tool()
|
| 66 |
+
def delete_file(path: str) -> bool:
|
| 67 |
+
# ensure path is in test_dir
|
| 68 |
+
if Path(path).resolve().parent != test_dir:
|
| 69 |
+
raise ValueError(f"Path must be in test_dir: {path}")
|
| 70 |
+
Path(path).unlink()
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
return mcp
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.mark.anyio
|
| 77 |
+
async def test_list_resources(mcp: FastMCP):
|
| 78 |
+
resources = await mcp.list_resources()
|
| 79 |
+
assert len(resources) == 4
|
| 80 |
+
|
| 81 |
+
assert [str(r.uri) for r in resources] == [
|
| 82 |
+
"dir://test_dir",
|
| 83 |
+
"file://test_dir/example.py",
|
| 84 |
+
"file://test_dir/readme.md",
|
| 85 |
+
"file://test_dir/config.json",
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.anyio
|
| 90 |
+
async def test_read_resource_dir(mcp: FastMCP):
|
| 91 |
+
res_iter = await mcp.read_resource("dir://test_dir")
|
| 92 |
+
res_list = list(res_iter)
|
| 93 |
+
assert len(res_list) == 1
|
| 94 |
+
res = res_list[0]
|
| 95 |
+
assert res.mime_type == "text/plain"
|
| 96 |
+
|
| 97 |
+
files = json.loads(res.content)
|
| 98 |
+
|
| 99 |
+
assert sorted([Path(f).name for f in files]) == [
|
| 100 |
+
"config.json",
|
| 101 |
+
"example.py",
|
| 102 |
+
"readme.md",
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@pytest.mark.anyio
|
| 107 |
+
async def test_read_resource_file(mcp: FastMCP):
|
| 108 |
+
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
| 109 |
+
res_list = list(res_iter)
|
| 110 |
+
assert len(res_list) == 1
|
| 111 |
+
res = res_list[0]
|
| 112 |
+
assert res.content == "print('hello world')"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@pytest.mark.anyio
|
| 116 |
+
async def test_delete_file(mcp: FastMCP, test_dir: Path):
|
| 117 |
+
await mcp.call_tool(
|
| 118 |
+
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
|
| 119 |
+
)
|
| 120 |
+
assert not (test_dir / "example.py").exists()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@pytest.mark.anyio
|
| 124 |
+
async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
|
| 125 |
+
await mcp.call_tool(
|
| 126 |
+
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
|
| 127 |
+
)
|
| 128 |
+
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
| 129 |
+
res_list = list(res_iter)
|
| 130 |
+
assert len(res_list) == 1
|
| 131 |
+
res = res_list[0]
|
| 132 |
+
assert res.content == "File not found"
|
tests/server/test_lifespan.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for lifespan functionality in both low-level and FastMCP servers."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import AsyncIterator
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
|
| 6 |
+
import anyio
|
| 7 |
+
import pytest
|
| 8 |
+
from mcp.types import (
|
| 9 |
+
ClientCapabilities,
|
| 10 |
+
Implementation,
|
| 11 |
+
InitializeRequestParams,
|
| 12 |
+
JSONRPCMessage,
|
| 13 |
+
JSONRPCNotification,
|
| 14 |
+
JSONRPCRequest,
|
| 15 |
+
)
|
| 16 |
+
from pydantic import TypeAdapter
|
| 17 |
+
|
| 18 |
+
from fastmcp import Context, FastMCP
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.anyio
|
| 22 |
+
async def test_fastmcp_server_lifespan():
|
| 23 |
+
"""Test that lifespan works in FastMCP server."""
|
| 24 |
+
|
| 25 |
+
@asynccontextmanager
|
| 26 |
+
async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]:
|
| 27 |
+
"""Test lifespan context that tracks startup/shutdown."""
|
| 28 |
+
context = {"started": False, "shutdown": False}
|
| 29 |
+
try:
|
| 30 |
+
context["started"] = True
|
| 31 |
+
yield context
|
| 32 |
+
finally:
|
| 33 |
+
context["shutdown"] = True
|
| 34 |
+
|
| 35 |
+
server = FastMCP("test", lifespan=test_lifespan)
|
| 36 |
+
|
| 37 |
+
# Create memory streams for testing
|
| 38 |
+
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
|
| 39 |
+
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
|
| 40 |
+
|
| 41 |
+
# Add a tool that checks lifespan context
|
| 42 |
+
@server.tool()
|
| 43 |
+
def check_lifespan(ctx: Context) -> bool:
|
| 44 |
+
"""Tool that checks lifespan context."""
|
| 45 |
+
assert isinstance(ctx.request_context.lifespan_context, dict)
|
| 46 |
+
assert ctx.request_context.lifespan_context["started"]
|
| 47 |
+
assert not ctx.request_context.lifespan_context["shutdown"]
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
# Run server in background task
|
| 51 |
+
async with (
|
| 52 |
+
anyio.create_task_group() as tg,
|
| 53 |
+
send_stream1,
|
| 54 |
+
receive_stream1,
|
| 55 |
+
send_stream2,
|
| 56 |
+
receive_stream2,
|
| 57 |
+
):
|
| 58 |
+
|
| 59 |
+
async def run_server():
|
| 60 |
+
await server._mcp_server.run(
|
| 61 |
+
receive_stream1,
|
| 62 |
+
send_stream2,
|
| 63 |
+
server._mcp_server.create_initialization_options(),
|
| 64 |
+
raise_exceptions=True,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
tg.start_soon(run_server)
|
| 68 |
+
|
| 69 |
+
# Initialize the server
|
| 70 |
+
params = InitializeRequestParams(
|
| 71 |
+
protocolVersion="2024-11-05",
|
| 72 |
+
capabilities=ClientCapabilities(),
|
| 73 |
+
clientInfo=Implementation(name="test-client", version="0.1.0"),
|
| 74 |
+
)
|
| 75 |
+
await send_stream1.send(
|
| 76 |
+
JSONRPCMessage(
|
| 77 |
+
root=JSONRPCRequest(
|
| 78 |
+
jsonrpc="2.0",
|
| 79 |
+
id=1,
|
| 80 |
+
method="initialize",
|
| 81 |
+
params=TypeAdapter(InitializeRequestParams).dump_python(params),
|
| 82 |
+
)
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
response = await receive_stream2.receive()
|
| 86 |
+
|
| 87 |
+
# Send initialized notification
|
| 88 |
+
await send_stream1.send(
|
| 89 |
+
JSONRPCMessage(
|
| 90 |
+
root=JSONRPCNotification(
|
| 91 |
+
jsonrpc="2.0",
|
| 92 |
+
method="notifications/initialized",
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Call the tool to verify lifespan context
|
| 98 |
+
await send_stream1.send(
|
| 99 |
+
JSONRPCMessage(
|
| 100 |
+
root=JSONRPCRequest(
|
| 101 |
+
jsonrpc="2.0",
|
| 102 |
+
id=2,
|
| 103 |
+
method="tools/call",
|
| 104 |
+
params={"name": "check_lifespan", "arguments": {}},
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Get response and verify
|
| 110 |
+
response = await receive_stream2.receive()
|
| 111 |
+
assert response.root.result["content"][0]["text"] == "true"
|
| 112 |
+
|
| 113 |
+
# Cancel server task
|
| 114 |
+
tg.cancel_scope.cancel()
|
tests/server/test_server.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from mcp.shared.exceptions import McpError
|
| 7 |
+
from mcp.shared.memory import (
|
| 8 |
+
create_connected_server_and_client_session as client_session,
|
| 9 |
+
)
|
| 10 |
+
from mcp.types import (
|
| 11 |
+
BlobResourceContents,
|
| 12 |
+
ImageContent,
|
| 13 |
+
TextContent,
|
| 14 |
+
TextResourceContents,
|
| 15 |
+
)
|
| 16 |
+
from pydantic import AnyUrl, Field
|
| 17 |
+
|
| 18 |
+
from fastmcp import Context, FastMCP
|
| 19 |
+
from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
|
| 20 |
+
from fastmcp.resources import FileResource, FunctionResource
|
| 21 |
+
from fastmcp.utilities.types import Image
|
| 22 |
+
|
| 23 |
+
if TYPE_CHECKING:
|
| 24 |
+
from fastmcp import Context
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestServer:
|
| 28 |
+
@pytest.mark.anyio
|
| 29 |
+
async def test_create_server(self):
|
| 30 |
+
mcp = FastMCP(instructions="Server instructions")
|
| 31 |
+
assert mcp.name == "FastMCP"
|
| 32 |
+
assert mcp.instructions == "Server instructions"
|
| 33 |
+
|
| 34 |
+
@pytest.mark.anyio
|
| 35 |
+
async def test_non_ascii_description(self):
|
| 36 |
+
"""Test that FastMCP handles non-ASCII characters in descriptions correctly"""
|
| 37 |
+
mcp = FastMCP()
|
| 38 |
+
|
| 39 |
+
@mcp.tool(
|
| 40 |
+
description=(
|
| 41 |
+
"🌟 This tool uses emojis and UTF-8 characters: á é í ó ú ñ 漢字 🎉"
|
| 42 |
+
)
|
| 43 |
+
)
|
| 44 |
+
def hello_world(name: str = "世界") -> str:
|
| 45 |
+
return f"¡Hola, {name}! 👋"
|
| 46 |
+
|
| 47 |
+
async with client_session(mcp._mcp_server) as client:
|
| 48 |
+
tools = await client.list_tools()
|
| 49 |
+
assert len(tools.tools) == 1
|
| 50 |
+
tool = tools.tools[0]
|
| 51 |
+
assert tool.description is not None
|
| 52 |
+
assert "🌟" in tool.description
|
| 53 |
+
assert "漢字" in tool.description
|
| 54 |
+
assert "🎉" in tool.description
|
| 55 |
+
|
| 56 |
+
result = await client.call_tool("hello_world", {})
|
| 57 |
+
assert len(result.content) == 1
|
| 58 |
+
content = result.content[0]
|
| 59 |
+
assert isinstance(content, TextContent)
|
| 60 |
+
assert "¡Hola, 世界! 👋" == content.text
|
| 61 |
+
|
| 62 |
+
@pytest.mark.anyio
|
| 63 |
+
async def test_add_tool_decorator(self):
|
| 64 |
+
mcp = FastMCP()
|
| 65 |
+
|
| 66 |
+
@mcp.tool()
|
| 67 |
+
def add(x: int, y: int) -> int:
|
| 68 |
+
return x + y
|
| 69 |
+
|
| 70 |
+
assert len(mcp._tool_manager.list_tools()) == 1
|
| 71 |
+
|
| 72 |
+
@pytest.mark.anyio
|
| 73 |
+
async def test_add_tool_decorator_incorrect_usage(self):
|
| 74 |
+
mcp = FastMCP()
|
| 75 |
+
|
| 76 |
+
with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
|
| 77 |
+
|
| 78 |
+
@mcp.tool # Missing parentheses #type: ignore
|
| 79 |
+
def add(x: int, y: int) -> int:
|
| 80 |
+
return x + y
|
| 81 |
+
|
| 82 |
+
@pytest.mark.anyio
|
| 83 |
+
async def test_add_resource_decorator(self):
|
| 84 |
+
mcp = FastMCP()
|
| 85 |
+
|
| 86 |
+
@mcp.resource("r://{x}")
|
| 87 |
+
def get_data(x: str) -> str:
|
| 88 |
+
return f"Data: {x}"
|
| 89 |
+
|
| 90 |
+
assert len(mcp._resource_manager._templates) == 1
|
| 91 |
+
|
| 92 |
+
@pytest.mark.anyio
|
| 93 |
+
async def test_add_resource_decorator_incorrect_usage(self):
|
| 94 |
+
mcp = FastMCP()
|
| 95 |
+
|
| 96 |
+
with pytest.raises(
|
| 97 |
+
TypeError, match="The @resource decorator was used incorrectly"
|
| 98 |
+
):
|
| 99 |
+
|
| 100 |
+
@mcp.resource # Missing parentheses #type: ignore
|
| 101 |
+
def get_data(x: str) -> str:
|
| 102 |
+
return f"Data: {x}"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def tool_fn(x: int, y: int) -> int:
|
| 106 |
+
return x + y
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def error_tool_fn() -> None:
|
| 110 |
+
raise ValueError("Test error")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def image_tool_fn(path: str) -> Image:
|
| 114 |
+
return Image(path)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def mixed_content_tool_fn() -> list[TextContent | ImageContent]:
|
| 118 |
+
return [
|
| 119 |
+
TextContent(type="text", text="Hello"),
|
| 120 |
+
ImageContent(type="image", data="abc", mimeType="image/png"),
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class TestServerTools:
|
| 125 |
+
@pytest.mark.anyio
|
| 126 |
+
async def test_add_tool(self):
|
| 127 |
+
mcp = FastMCP()
|
| 128 |
+
mcp.add_tool(tool_fn)
|
| 129 |
+
mcp.add_tool(tool_fn)
|
| 130 |
+
assert len(mcp._tool_manager.list_tools()) == 1
|
| 131 |
+
|
| 132 |
+
@pytest.mark.anyio
|
| 133 |
+
async def test_list_tools(self):
|
| 134 |
+
mcp = FastMCP()
|
| 135 |
+
mcp.add_tool(tool_fn)
|
| 136 |
+
async with client_session(mcp._mcp_server) as client:
|
| 137 |
+
tools = await client.list_tools()
|
| 138 |
+
assert len(tools.tools) == 1
|
| 139 |
+
|
| 140 |
+
@pytest.mark.anyio
|
| 141 |
+
async def test_call_tool(self):
|
| 142 |
+
mcp = FastMCP()
|
| 143 |
+
mcp.add_tool(tool_fn)
|
| 144 |
+
async with client_session(mcp._mcp_server) as client:
|
| 145 |
+
result = await client.call_tool("my_tool", {"arg1": "value"})
|
| 146 |
+
assert not hasattr(result, "error")
|
| 147 |
+
assert len(result.content) > 0
|
| 148 |
+
|
| 149 |
+
@pytest.mark.anyio
|
| 150 |
+
async def test_tool_exception_handling(self):
|
| 151 |
+
mcp = FastMCP()
|
| 152 |
+
mcp.add_tool(error_tool_fn)
|
| 153 |
+
async with client_session(mcp._mcp_server) as client:
|
| 154 |
+
result = await client.call_tool("error_tool_fn", {})
|
| 155 |
+
assert len(result.content) == 1
|
| 156 |
+
content = result.content[0]
|
| 157 |
+
assert isinstance(content, TextContent)
|
| 158 |
+
assert "Test error" in content.text
|
| 159 |
+
assert result.isError is True
|
| 160 |
+
|
| 161 |
+
@pytest.mark.anyio
|
| 162 |
+
async def test_tool_error_handling(self):
|
| 163 |
+
mcp = FastMCP()
|
| 164 |
+
mcp.add_tool(error_tool_fn)
|
| 165 |
+
async with client_session(mcp._mcp_server) as client:
|
| 166 |
+
result = await client.call_tool("error_tool_fn", {})
|
| 167 |
+
assert len(result.content) == 1
|
| 168 |
+
content = result.content[0]
|
| 169 |
+
assert isinstance(content, TextContent)
|
| 170 |
+
assert "Test error" in content.text
|
| 171 |
+
assert result.isError is True
|
| 172 |
+
|
| 173 |
+
@pytest.mark.anyio
|
| 174 |
+
async def test_tool_error_details(self):
|
| 175 |
+
"""Test that exception details are properly formatted in the response"""
|
| 176 |
+
mcp = FastMCP()
|
| 177 |
+
mcp.add_tool(error_tool_fn)
|
| 178 |
+
async with client_session(mcp._mcp_server) as client:
|
| 179 |
+
result = await client.call_tool("error_tool_fn", {})
|
| 180 |
+
content = result.content[0]
|
| 181 |
+
assert isinstance(content, TextContent)
|
| 182 |
+
assert isinstance(content.text, str)
|
| 183 |
+
assert "Test error" in content.text
|
| 184 |
+
assert result.isError is True
|
| 185 |
+
|
| 186 |
+
@pytest.mark.anyio
|
| 187 |
+
async def test_tool_return_value_conversion(self):
|
| 188 |
+
mcp = FastMCP()
|
| 189 |
+
mcp.add_tool(tool_fn)
|
| 190 |
+
async with client_session(mcp._mcp_server) as client:
|
| 191 |
+
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
|
| 192 |
+
assert len(result.content) == 1
|
| 193 |
+
content = result.content[0]
|
| 194 |
+
assert isinstance(content, TextContent)
|
| 195 |
+
assert content.text == "3"
|
| 196 |
+
|
| 197 |
+
@pytest.mark.anyio
|
| 198 |
+
async def test_tool_image_helper(self, tmp_path: Path):
|
| 199 |
+
# Create a test image
|
| 200 |
+
image_path = tmp_path / "test.png"
|
| 201 |
+
image_path.write_bytes(b"fake png data")
|
| 202 |
+
|
| 203 |
+
mcp = FastMCP()
|
| 204 |
+
mcp.add_tool(image_tool_fn)
|
| 205 |
+
async with client_session(mcp._mcp_server) as client:
|
| 206 |
+
result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
|
| 207 |
+
assert len(result.content) == 1
|
| 208 |
+
content = result.content[0]
|
| 209 |
+
assert isinstance(content, ImageContent)
|
| 210 |
+
assert content.type == "image"
|
| 211 |
+
assert content.mimeType == "image/png"
|
| 212 |
+
# Verify base64 encoding
|
| 213 |
+
decoded = base64.b64decode(content.data)
|
| 214 |
+
assert decoded == b"fake png data"
|
| 215 |
+
|
| 216 |
+
@pytest.mark.anyio
|
| 217 |
+
async def test_tool_mixed_content(self):
|
| 218 |
+
mcp = FastMCP()
|
| 219 |
+
mcp.add_tool(mixed_content_tool_fn)
|
| 220 |
+
async with client_session(mcp._mcp_server) as client:
|
| 221 |
+
result = await client.call_tool("mixed_content_tool_fn", {})
|
| 222 |
+
assert len(result.content) == 2
|
| 223 |
+
content1 = result.content[0]
|
| 224 |
+
content2 = result.content[1]
|
| 225 |
+
assert isinstance(content1, TextContent)
|
| 226 |
+
assert content1.text == "Hello"
|
| 227 |
+
assert isinstance(content2, ImageContent)
|
| 228 |
+
assert content2.mimeType == "image/png"
|
| 229 |
+
assert content2.data == "abc"
|
| 230 |
+
|
| 231 |
+
@pytest.mark.anyio
|
| 232 |
+
async def test_tool_mixed_list_with_image(self, tmp_path: Path):
|
| 233 |
+
"""Test that lists containing Image objects and other types are handled
|
| 234 |
+
correctly"""
|
| 235 |
+
# Create a test image
|
| 236 |
+
image_path = tmp_path / "test.png"
|
| 237 |
+
image_path.write_bytes(b"test image data")
|
| 238 |
+
|
| 239 |
+
def mixed_list_fn() -> list:
|
| 240 |
+
return [
|
| 241 |
+
"text message",
|
| 242 |
+
Image(image_path),
|
| 243 |
+
{"key": "value"},
|
| 244 |
+
TextContent(type="text", text="direct content"),
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
mcp = FastMCP()
|
| 248 |
+
mcp.add_tool(mixed_list_fn)
|
| 249 |
+
async with client_session(mcp._mcp_server) as client:
|
| 250 |
+
result = await client.call_tool("mixed_list_fn", {})
|
| 251 |
+
assert len(result.content) == 4
|
| 252 |
+
# Check text conversion
|
| 253 |
+
content1 = result.content[0]
|
| 254 |
+
assert isinstance(content1, TextContent)
|
| 255 |
+
assert content1.text == "text message"
|
| 256 |
+
# Check image conversion
|
| 257 |
+
content2 = result.content[1]
|
| 258 |
+
assert isinstance(content2, ImageContent)
|
| 259 |
+
assert content2.mimeType == "image/png"
|
| 260 |
+
assert base64.b64decode(content2.data) == b"test image data"
|
| 261 |
+
# Check dict conversion
|
| 262 |
+
content3 = result.content[2]
|
| 263 |
+
assert isinstance(content3, TextContent)
|
| 264 |
+
assert '"key": "value"' in content3.text
|
| 265 |
+
# Check direct TextContent
|
| 266 |
+
content4 = result.content[3]
|
| 267 |
+
assert isinstance(content4, TextContent)
|
| 268 |
+
assert content4.text == "direct content"
|
| 269 |
+
|
| 270 |
+
async def test_parameter_descriptions(self):
|
| 271 |
+
mcp = FastMCP("Test Server")
|
| 272 |
+
|
| 273 |
+
@mcp.tool()
|
| 274 |
+
def greet(
|
| 275 |
+
name: str = Field(description="The name to greet"),
|
| 276 |
+
title: str = Field(description="Optional title", default=""),
|
| 277 |
+
) -> str:
|
| 278 |
+
"""A greeting tool"""
|
| 279 |
+
return f"Hello {title} {name}"
|
| 280 |
+
|
| 281 |
+
tools = await mcp.list_tools()
|
| 282 |
+
assert len(tools) == 1
|
| 283 |
+
tool = tools[0]
|
| 284 |
+
|
| 285 |
+
# Check that parameter descriptions are present in the schema
|
| 286 |
+
properties = tool.inputSchema["properties"]
|
| 287 |
+
assert "name" in properties
|
| 288 |
+
assert properties["name"]["description"] == "The name to greet"
|
| 289 |
+
assert "title" in properties
|
| 290 |
+
assert properties["title"]["description"] == "Optional title"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
class TestServerResources:
|
| 294 |
+
@pytest.mark.anyio
|
| 295 |
+
async def test_text_resource(self):
|
| 296 |
+
mcp = FastMCP()
|
| 297 |
+
|
| 298 |
+
def get_text():
|
| 299 |
+
return "Hello, world!"
|
| 300 |
+
|
| 301 |
+
resource = FunctionResource(
|
| 302 |
+
uri=AnyUrl("resource://test"), name="test", fn=get_text
|
| 303 |
+
)
|
| 304 |
+
mcp.add_resource(resource)
|
| 305 |
+
|
| 306 |
+
async with client_session(mcp._mcp_server) as client:
|
| 307 |
+
result = await client.read_resource(AnyUrl("resource://test"))
|
| 308 |
+
assert isinstance(result.contents[0], TextResourceContents)
|
| 309 |
+
assert result.contents[0].text == "Hello, world!"
|
| 310 |
+
|
| 311 |
+
@pytest.mark.anyio
|
| 312 |
+
async def test_binary_resource(self):
|
| 313 |
+
mcp = FastMCP()
|
| 314 |
+
|
| 315 |
+
def get_binary():
|
| 316 |
+
return b"Binary data"
|
| 317 |
+
|
| 318 |
+
resource = FunctionResource(
|
| 319 |
+
uri=AnyUrl("resource://binary"),
|
| 320 |
+
name="binary",
|
| 321 |
+
fn=get_binary,
|
| 322 |
+
mime_type="application/octet-stream",
|
| 323 |
+
)
|
| 324 |
+
mcp.add_resource(resource)
|
| 325 |
+
|
| 326 |
+
async with client_session(mcp._mcp_server) as client:
|
| 327 |
+
result = await client.read_resource(AnyUrl("resource://binary"))
|
| 328 |
+
assert isinstance(result.contents[0], BlobResourceContents)
|
| 329 |
+
assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
|
| 330 |
+
|
| 331 |
+
@pytest.mark.anyio
|
| 332 |
+
async def test_file_resource_text(self, tmp_path: Path):
|
| 333 |
+
mcp = FastMCP()
|
| 334 |
+
|
| 335 |
+
# Create a text file
|
| 336 |
+
text_file = tmp_path / "test.txt"
|
| 337 |
+
text_file.write_text("Hello from file!")
|
| 338 |
+
|
| 339 |
+
resource = FileResource(
|
| 340 |
+
uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
|
| 341 |
+
)
|
| 342 |
+
mcp.add_resource(resource)
|
| 343 |
+
|
| 344 |
+
async with client_session(mcp._mcp_server) as client:
|
| 345 |
+
result = await client.read_resource(AnyUrl("file://test.txt"))
|
| 346 |
+
assert isinstance(result.contents[0], TextResourceContents)
|
| 347 |
+
assert result.contents[0].text == "Hello from file!"
|
| 348 |
+
|
| 349 |
+
@pytest.mark.anyio
|
| 350 |
+
async def test_file_resource_binary(self, tmp_path: Path):
|
| 351 |
+
mcp = FastMCP()
|
| 352 |
+
|
| 353 |
+
# Create a binary file
|
| 354 |
+
binary_file = tmp_path / "test.bin"
|
| 355 |
+
binary_file.write_bytes(b"Binary file data")
|
| 356 |
+
|
| 357 |
+
resource = FileResource(
|
| 358 |
+
uri=AnyUrl("file://test.bin"),
|
| 359 |
+
name="test.bin",
|
| 360 |
+
path=binary_file,
|
| 361 |
+
mime_type="application/octet-stream",
|
| 362 |
+
)
|
| 363 |
+
mcp.add_resource(resource)
|
| 364 |
+
|
| 365 |
+
async with client_session(mcp._mcp_server) as client:
|
| 366 |
+
result = await client.read_resource(AnyUrl("file://test.bin"))
|
| 367 |
+
assert isinstance(result.contents[0], BlobResourceContents)
|
| 368 |
+
assert (
|
| 369 |
+
result.contents[0].blob
|
| 370 |
+
== base64.b64encode(b"Binary file data").decode()
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
class TestServerResourceTemplates:
|
| 375 |
+
@pytest.mark.anyio
|
| 376 |
+
async def test_resource_with_params(self):
|
| 377 |
+
"""Test that a resource with function parameters raises an error if the URI
|
| 378 |
+
parameters don't match"""
|
| 379 |
+
mcp = FastMCP()
|
| 380 |
+
|
| 381 |
+
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
|
| 382 |
+
|
| 383 |
+
@mcp.resource("resource://data")
|
| 384 |
+
def get_data_fn(param: str) -> str:
|
| 385 |
+
return f"Data: {param}"
|
| 386 |
+
|
| 387 |
+
@pytest.mark.anyio
|
| 388 |
+
async def test_resource_with_uri_params(self):
|
| 389 |
+
"""Test that a resource with URI parameters is automatically a template"""
|
| 390 |
+
mcp = FastMCP()
|
| 391 |
+
|
| 392 |
+
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
|
| 393 |
+
|
| 394 |
+
@mcp.resource("resource://{param}")
|
| 395 |
+
def get_data() -> str:
|
| 396 |
+
return "Data"
|
| 397 |
+
|
| 398 |
+
@pytest.mark.anyio
|
| 399 |
+
async def test_resource_with_untyped_params(self):
|
| 400 |
+
"""Test that a resource with untyped parameters raises an error"""
|
| 401 |
+
mcp = FastMCP()
|
| 402 |
+
|
| 403 |
+
@mcp.resource("resource://{param}")
|
| 404 |
+
def get_data(param) -> str:
|
| 405 |
+
return "Data"
|
| 406 |
+
|
| 407 |
+
@pytest.mark.anyio
|
| 408 |
+
async def test_resource_matching_params(self):
|
| 409 |
+
"""Test that a resource with matching URI and function parameters works"""
|
| 410 |
+
mcp = FastMCP()
|
| 411 |
+
|
| 412 |
+
@mcp.resource("resource://{name}/data")
|
| 413 |
+
def get_data(name: str) -> str:
|
| 414 |
+
return f"Data for {name}"
|
| 415 |
+
|
| 416 |
+
async with client_session(mcp._mcp_server) as client:
|
| 417 |
+
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 418 |
+
assert isinstance(result.contents[0], TextResourceContents)
|
| 419 |
+
assert result.contents[0].text == "Data for test"
|
| 420 |
+
|
| 421 |
+
@pytest.mark.anyio
|
| 422 |
+
async def test_resource_mismatched_params(self):
|
| 423 |
+
"""Test that mismatched parameters raise an error"""
|
| 424 |
+
mcp = FastMCP()
|
| 425 |
+
|
| 426 |
+
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
|
| 427 |
+
|
| 428 |
+
@mcp.resource("resource://{name}/data")
|
| 429 |
+
def get_data(user: str) -> str:
|
| 430 |
+
return f"Data for {user}"
|
| 431 |
+
|
| 432 |
+
@pytest.mark.anyio
|
| 433 |
+
async def test_resource_multiple_params(self):
|
| 434 |
+
"""Test that multiple parameters work correctly"""
|
| 435 |
+
mcp = FastMCP()
|
| 436 |
+
|
| 437 |
+
@mcp.resource("resource://{org}/{repo}/data")
|
| 438 |
+
def get_data(org: str, repo: str) -> str:
|
| 439 |
+
return f"Data for {org}/{repo}"
|
| 440 |
+
|
| 441 |
+
async with client_session(mcp._mcp_server) as client:
|
| 442 |
+
result = await client.read_resource(
|
| 443 |
+
AnyUrl("resource://cursor/fastmcp/data")
|
| 444 |
+
)
|
| 445 |
+
assert isinstance(result.contents[0], TextResourceContents)
|
| 446 |
+
assert result.contents[0].text == "Data for cursor/fastmcp"
|
| 447 |
+
|
| 448 |
+
@pytest.mark.anyio
|
| 449 |
+
async def test_resource_multiple_mismatched_params(self):
|
| 450 |
+
"""Test that mismatched parameters raise an error"""
|
| 451 |
+
mcp = FastMCP()
|
| 452 |
+
|
| 453 |
+
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
|
| 454 |
+
|
| 455 |
+
@mcp.resource("resource://{org}/{repo}/data")
|
| 456 |
+
def get_data_mismatched(org: str, repo_2: str) -> str:
|
| 457 |
+
return f"Data for {org}"
|
| 458 |
+
|
| 459 |
+
"""Test that a resource with no parameters works as a regular resource"""
|
| 460 |
+
mcp = FastMCP()
|
| 461 |
+
|
| 462 |
+
@mcp.resource("resource://static")
|
| 463 |
+
def get_static_data() -> str:
|
| 464 |
+
return "Static data"
|
| 465 |
+
|
| 466 |
+
async with client_session(mcp._mcp_server) as client:
|
| 467 |
+
result = await client.read_resource(AnyUrl("resource://static"))
|
| 468 |
+
assert isinstance(result.contents[0], TextResourceContents)
|
| 469 |
+
assert result.contents[0].text == "Static data"
|
| 470 |
+
|
| 471 |
+
@pytest.mark.anyio
|
| 472 |
+
async def test_template_to_resource_conversion(self):
|
| 473 |
+
"""Test that templates are properly converted to resources when accessed"""
|
| 474 |
+
mcp = FastMCP()
|
| 475 |
+
|
| 476 |
+
@mcp.resource("resource://{name}/data")
|
| 477 |
+
def get_data(name: str) -> str:
|
| 478 |
+
return f"Data for {name}"
|
| 479 |
+
|
| 480 |
+
# Should be registered as a template
|
| 481 |
+
assert len(mcp._resource_manager._templates) == 1
|
| 482 |
+
assert len(await mcp.list_resources()) == 0
|
| 483 |
+
|
| 484 |
+
# When accessed, should create a concrete resource
|
| 485 |
+
resource = await mcp._resource_manager.get_resource("resource://test/data")
|
| 486 |
+
assert isinstance(resource, FunctionResource)
|
| 487 |
+
result = await resource.read()
|
| 488 |
+
assert result == "Data for test"
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
class TestContextInjection:
|
| 492 |
+
"""Test context injection in tools."""
|
| 493 |
+
|
| 494 |
+
@pytest.mark.anyio
|
| 495 |
+
async def test_context_detection(self):
|
| 496 |
+
"""Test that context parameters are properly detected."""
|
| 497 |
+
mcp = FastMCP()
|
| 498 |
+
|
| 499 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 500 |
+
return f"Request {ctx.request_id}: {x}"
|
| 501 |
+
|
| 502 |
+
tool = mcp._tool_manager.add_tool(tool_with_context)
|
| 503 |
+
assert tool.context_kwarg == "ctx"
|
| 504 |
+
|
| 505 |
+
@pytest.mark.anyio
|
| 506 |
+
async def test_context_injection(self):
|
| 507 |
+
"""Test that context is properly injected into tool calls."""
|
| 508 |
+
mcp = FastMCP()
|
| 509 |
+
|
| 510 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 511 |
+
assert ctx.request_id is not None
|
| 512 |
+
return f"Request {ctx.request_id}: {x}"
|
| 513 |
+
|
| 514 |
+
mcp.add_tool(tool_with_context)
|
| 515 |
+
async with client_session(mcp._mcp_server) as client:
|
| 516 |
+
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 517 |
+
assert len(result.content) == 1
|
| 518 |
+
content = result.content[0]
|
| 519 |
+
assert isinstance(content, TextContent)
|
| 520 |
+
assert "Request" in content.text
|
| 521 |
+
assert "42" in content.text
|
| 522 |
+
|
| 523 |
+
@pytest.mark.anyio
|
| 524 |
+
async def test_async_context(self):
|
| 525 |
+
"""Test that context works in async functions."""
|
| 526 |
+
mcp = FastMCP()
|
| 527 |
+
|
| 528 |
+
async def async_tool(x: int, ctx: Context) -> str:
|
| 529 |
+
assert ctx.request_id is not None
|
| 530 |
+
return f"Async request {ctx.request_id}: {x}"
|
| 531 |
+
|
| 532 |
+
mcp.add_tool(async_tool)
|
| 533 |
+
async with client_session(mcp._mcp_server) as client:
|
| 534 |
+
result = await client.call_tool("async_tool", {"x": 42})
|
| 535 |
+
assert len(result.content) == 1
|
| 536 |
+
content = result.content[0]
|
| 537 |
+
assert isinstance(content, TextContent)
|
| 538 |
+
assert "Async request" in content.text
|
| 539 |
+
assert "42" in content.text
|
| 540 |
+
|
| 541 |
+
@pytest.mark.anyio
|
| 542 |
+
async def test_context_logging(self):
|
| 543 |
+
from unittest.mock import patch
|
| 544 |
+
|
| 545 |
+
import mcp.server.session
|
| 546 |
+
|
| 547 |
+
"""Test that context logging methods work."""
|
| 548 |
+
mcp = FastMCP()
|
| 549 |
+
|
| 550 |
+
async def logging_tool(msg: str, ctx: Context) -> str:
|
| 551 |
+
await ctx.debug("Debug message")
|
| 552 |
+
await ctx.info("Info message")
|
| 553 |
+
await ctx.warning("Warning message")
|
| 554 |
+
await ctx.error("Error message")
|
| 555 |
+
return f"Logged messages for {msg}"
|
| 556 |
+
|
| 557 |
+
mcp.add_tool(logging_tool)
|
| 558 |
+
|
| 559 |
+
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
|
| 560 |
+
async with client_session(mcp._mcp_server) as client:
|
| 561 |
+
result = await client.call_tool("logging_tool", {"msg": "test"})
|
| 562 |
+
assert len(result.content) == 1
|
| 563 |
+
content = result.content[0]
|
| 564 |
+
assert isinstance(content, TextContent)
|
| 565 |
+
assert "Logged messages for test" in content.text
|
| 566 |
+
|
| 567 |
+
assert mock_log.call_count == 4
|
| 568 |
+
mock_log.assert_any_call(
|
| 569 |
+
level="debug", data="Debug message", logger=None
|
| 570 |
+
)
|
| 571 |
+
mock_log.assert_any_call(level="info", data="Info message", logger=None)
|
| 572 |
+
mock_log.assert_any_call(
|
| 573 |
+
level="warning", data="Warning message", logger=None
|
| 574 |
+
)
|
| 575 |
+
mock_log.assert_any_call(
|
| 576 |
+
level="error", data="Error message", logger=None
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
@pytest.mark.anyio
|
| 580 |
+
async def test_optional_context(self):
|
| 581 |
+
"""Test that context is optional."""
|
| 582 |
+
mcp = FastMCP()
|
| 583 |
+
|
| 584 |
+
def no_context(x: int) -> int:
|
| 585 |
+
return x * 2
|
| 586 |
+
|
| 587 |
+
mcp.add_tool(no_context)
|
| 588 |
+
async with client_session(mcp._mcp_server) as client:
|
| 589 |
+
result = await client.call_tool("no_context", {"x": 21})
|
| 590 |
+
assert len(result.content) == 1
|
| 591 |
+
content = result.content[0]
|
| 592 |
+
assert isinstance(content, TextContent)
|
| 593 |
+
assert content.text == "42"
|
| 594 |
+
|
| 595 |
+
@pytest.mark.anyio
|
| 596 |
+
async def test_context_resource_access(self):
|
| 597 |
+
"""Test that context can access resources."""
|
| 598 |
+
mcp = FastMCP()
|
| 599 |
+
|
| 600 |
+
@mcp.resource("test://data")
|
| 601 |
+
def test_resource() -> str:
|
| 602 |
+
return "resource data"
|
| 603 |
+
|
| 604 |
+
@mcp.tool()
|
| 605 |
+
async def tool_with_resource(ctx: Context) -> str:
|
| 606 |
+
r_iter = await ctx.read_resource("test://data")
|
| 607 |
+
r_list = list(r_iter)
|
| 608 |
+
assert len(r_list) == 1
|
| 609 |
+
r = r_list[0]
|
| 610 |
+
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
| 611 |
+
|
| 612 |
+
async with client_session(mcp._mcp_server) as client:
|
| 613 |
+
result = await client.call_tool("tool_with_resource", {})
|
| 614 |
+
assert len(result.content) == 1
|
| 615 |
+
content = result.content[0]
|
| 616 |
+
assert isinstance(content, TextContent)
|
| 617 |
+
assert "Read resource: resource data" in content.text
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
class TestServerPrompts:
|
| 621 |
+
"""Test prompt functionality in FastMCP server."""
|
| 622 |
+
|
| 623 |
+
@pytest.mark.anyio
|
| 624 |
+
async def test_prompt_decorator(self):
|
| 625 |
+
"""Test that the prompt decorator registers prompts correctly."""
|
| 626 |
+
mcp = FastMCP()
|
| 627 |
+
|
| 628 |
+
@mcp.prompt()
|
| 629 |
+
def fn() -> str:
|
| 630 |
+
return "Hello, world!"
|
| 631 |
+
|
| 632 |
+
prompts = mcp._prompt_manager.list_prompts()
|
| 633 |
+
assert len(prompts) == 1
|
| 634 |
+
assert prompts[0].name == "fn"
|
| 635 |
+
# Don't compare functions directly since validate_call wraps them
|
| 636 |
+
content = await prompts[0].render()
|
| 637 |
+
assert isinstance(content[0].content, TextContent)
|
| 638 |
+
assert content[0].content.text == "Hello, world!"
|
| 639 |
+
|
| 640 |
+
@pytest.mark.anyio
|
| 641 |
+
async def test_prompt_decorator_with_name(self):
|
| 642 |
+
"""Test prompt decorator with custom name."""
|
| 643 |
+
mcp = FastMCP()
|
| 644 |
+
|
| 645 |
+
@mcp.prompt(name="custom_name")
|
| 646 |
+
def fn() -> str:
|
| 647 |
+
return "Hello, world!"
|
| 648 |
+
|
| 649 |
+
prompts = mcp._prompt_manager.list_prompts()
|
| 650 |
+
assert len(prompts) == 1
|
| 651 |
+
assert prompts[0].name == "custom_name"
|
| 652 |
+
content = await prompts[0].render()
|
| 653 |
+
assert isinstance(content[0].content, TextContent)
|
| 654 |
+
assert content[0].content.text == "Hello, world!"
|
| 655 |
+
|
| 656 |
+
@pytest.mark.anyio
|
| 657 |
+
async def test_prompt_decorator_with_description(self):
|
| 658 |
+
"""Test prompt decorator with custom description."""
|
| 659 |
+
mcp = FastMCP()
|
| 660 |
+
|
| 661 |
+
@mcp.prompt(description="A custom description")
|
| 662 |
+
def fn() -> str:
|
| 663 |
+
return "Hello, world!"
|
| 664 |
+
|
| 665 |
+
prompts = mcp._prompt_manager.list_prompts()
|
| 666 |
+
assert len(prompts) == 1
|
| 667 |
+
assert prompts[0].description == "A custom description"
|
| 668 |
+
content = await prompts[0].render()
|
| 669 |
+
assert isinstance(content[0].content, TextContent)
|
| 670 |
+
assert content[0].content.text == "Hello, world!"
|
| 671 |
+
|
| 672 |
+
def test_prompt_decorator_error(self):
|
| 673 |
+
"""Test error when decorator is used incorrectly."""
|
| 674 |
+
mcp = FastMCP()
|
| 675 |
+
with pytest.raises(TypeError, match="decorator was used incorrectly"):
|
| 676 |
+
|
| 677 |
+
@mcp.prompt # type: ignore
|
| 678 |
+
def fn() -> str:
|
| 679 |
+
return "Hello, world!"
|
| 680 |
+
|
| 681 |
+
@pytest.mark.anyio
|
| 682 |
+
async def test_list_prompts(self):
|
| 683 |
+
"""Test listing prompts through MCP protocol."""
|
| 684 |
+
mcp = FastMCP()
|
| 685 |
+
|
| 686 |
+
@mcp.prompt()
|
| 687 |
+
def fn(name: str, optional: str = "default") -> str:
|
| 688 |
+
return f"Hello, {name}!"
|
| 689 |
+
|
| 690 |
+
async with client_session(mcp._mcp_server) as client:
|
| 691 |
+
result = await client.list_prompts()
|
| 692 |
+
assert result.prompts is not None
|
| 693 |
+
assert len(result.prompts) == 1
|
| 694 |
+
prompt = result.prompts[0]
|
| 695 |
+
assert prompt.name == "fn"
|
| 696 |
+
assert prompt.arguments is not None
|
| 697 |
+
assert len(prompt.arguments) == 2
|
| 698 |
+
assert prompt.arguments[0].name == "name"
|
| 699 |
+
assert prompt.arguments[0].required is True
|
| 700 |
+
assert prompt.arguments[1].name == "optional"
|
| 701 |
+
assert prompt.arguments[1].required is False
|
| 702 |
+
|
| 703 |
+
@pytest.mark.anyio
|
| 704 |
+
async def test_get_prompt(self):
|
| 705 |
+
"""Test getting a prompt through MCP protocol."""
|
| 706 |
+
mcp = FastMCP()
|
| 707 |
+
|
| 708 |
+
@mcp.prompt()
|
| 709 |
+
def fn(name: str) -> str:
|
| 710 |
+
return f"Hello, {name}!"
|
| 711 |
+
|
| 712 |
+
async with client_session(mcp._mcp_server) as client:
|
| 713 |
+
result = await client.get_prompt("fn", {"name": "World"})
|
| 714 |
+
assert len(result.messages) == 1
|
| 715 |
+
message = result.messages[0]
|
| 716 |
+
assert message.role == "user"
|
| 717 |
+
content = message.content
|
| 718 |
+
assert isinstance(content, TextContent)
|
| 719 |
+
assert content.text == "Hello, World!"
|
| 720 |
+
|
| 721 |
+
@pytest.mark.anyio
|
| 722 |
+
async def test_get_prompt_with_resource(self):
|
| 723 |
+
"""Test getting a prompt that returns resource content."""
|
| 724 |
+
mcp = FastMCP()
|
| 725 |
+
|
| 726 |
+
@mcp.prompt()
|
| 727 |
+
def fn() -> Message:
|
| 728 |
+
return UserMessage(
|
| 729 |
+
content=EmbeddedResource(
|
| 730 |
+
type="resource",
|
| 731 |
+
resource=TextResourceContents(
|
| 732 |
+
uri=AnyUrl("file://file.txt"),
|
| 733 |
+
text="File contents",
|
| 734 |
+
mimeType="text/plain",
|
| 735 |
+
),
|
| 736 |
+
)
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
async with client_session(mcp._mcp_server) as client:
|
| 740 |
+
result = await client.get_prompt("fn")
|
| 741 |
+
assert len(result.messages) == 1
|
| 742 |
+
message = result.messages[0]
|
| 743 |
+
assert message.role == "user"
|
| 744 |
+
content = message.content
|
| 745 |
+
assert isinstance(content, EmbeddedResource)
|
| 746 |
+
resource = content.resource
|
| 747 |
+
assert isinstance(resource, TextResourceContents)
|
| 748 |
+
assert resource.text == "File contents"
|
| 749 |
+
assert resource.mimeType == "text/plain"
|
| 750 |
+
|
| 751 |
+
@pytest.mark.anyio
|
| 752 |
+
async def test_get_unknown_prompt(self):
|
| 753 |
+
"""Test error when getting unknown prompt."""
|
| 754 |
+
mcp = FastMCP()
|
| 755 |
+
async with client_session(mcp._mcp_server) as client:
|
| 756 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 757 |
+
await client.get_prompt("unknown")
|
| 758 |
+
|
| 759 |
+
@pytest.mark.anyio
|
| 760 |
+
async def test_get_prompt_missing_args(self):
|
| 761 |
+
"""Test error when required arguments are missing."""
|
| 762 |
+
mcp = FastMCP()
|
| 763 |
+
|
| 764 |
+
@mcp.prompt()
|
| 765 |
+
def prompt_fn(name: str) -> str:
|
| 766 |
+
return f"Hello, {name}!"
|
| 767 |
+
|
| 768 |
+
async with client_session(mcp._mcp_server) as client:
|
| 769 |
+
with pytest.raises(McpError, match="Missing required arguments"):
|
| 770 |
+
await client.get_prompt("prompt_fn")
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -1,101 +1,431 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
# Setup source manager with tools
|
| 7 |
-
source_manager = ToolManager()
|
| 8 |
|
| 9 |
-
# Create some test tools
|
| 10 |
-
def tool1_fn():
|
| 11 |
-
return "Tool 1 result"
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
tool2_fn, name="process_data", description="Process the data"
|
| 20 |
-
)
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
assert (
|
| 40 |
-
target_manager._tools["source/process_data"].description == "Process the data"
|
| 41 |
-
)
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
source_manager = ToolManager()
|
| 53 |
-
target_manager = ToolManager()
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
def
|
| 75 |
-
|
| 76 |
-
# Setup source managers
|
| 77 |
-
weather_manager = ToolManager()
|
| 78 |
-
news_manager = ToolManager()
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
return "Weather forecast"
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
main_manager.import_tools(weather_manager, "weather/")
|
| 93 |
-
main_manager.import_tools(news_manager, "news/")
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
|
| 4 |
+
import pytest
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
|
| 7 |
+
from fastmcp.exceptions import ToolError
|
| 8 |
+
from fastmcp.tools import ToolManager
|
|
|
|
|
|
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
class TestAddTools:
|
| 12 |
+
def test_basic_function(self):
|
| 13 |
+
"""Test registering and running a basic function."""
|
| 14 |
|
| 15 |
+
def add(a: int, b: int) -> int:
|
| 16 |
+
"""Add two numbers."""
|
| 17 |
+
return a + b
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
manager = ToolManager()
|
| 20 |
+
manager.add_tool(add)
|
| 21 |
|
| 22 |
+
tool = manager.get_tool("add")
|
| 23 |
+
assert tool is not None
|
| 24 |
+
assert tool.name == "add"
|
| 25 |
+
assert tool.description == "Add two numbers."
|
| 26 |
+
assert tool.is_async is False
|
| 27 |
+
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
| 28 |
+
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
| 29 |
|
| 30 |
+
@pytest.mark.anyio
|
| 31 |
+
async def test_async_function(self):
|
| 32 |
+
"""Test registering and running an async function."""
|
| 33 |
|
| 34 |
+
async def fetch_data(url: str) -> str:
|
| 35 |
+
"""Fetch data from URL."""
|
| 36 |
+
return f"Data from {url}"
|
| 37 |
|
| 38 |
+
manager = ToolManager()
|
| 39 |
+
manager.add_tool(fetch_data)
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
tool = manager.get_tool("fetch_data")
|
| 42 |
+
assert tool is not None
|
| 43 |
+
assert tool.name == "fetch_data"
|
| 44 |
+
assert tool.description == "Fetch data from URL."
|
| 45 |
+
assert tool.is_async is True
|
| 46 |
+
assert tool.parameters["properties"]["url"]["type"] == "string"
|
| 47 |
|
| 48 |
+
def test_pydantic_model_function(self):
|
| 49 |
+
"""Test registering a function that takes a Pydantic model."""
|
| 50 |
|
| 51 |
+
class UserInput(BaseModel):
|
| 52 |
+
name: str
|
| 53 |
+
age: int
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
def create_user(user: UserInput, flag: bool) -> dict:
|
| 56 |
+
"""Create a new user."""
|
| 57 |
+
return {"id": 1, **user.model_dump()}
|
| 58 |
|
| 59 |
+
manager = ToolManager()
|
| 60 |
+
manager.add_tool(create_user)
|
| 61 |
|
| 62 |
+
tool = manager.get_tool("create_user")
|
| 63 |
+
assert tool is not None
|
| 64 |
+
assert tool.name == "create_user"
|
| 65 |
+
assert tool.description == "Create a new user."
|
| 66 |
+
assert tool.is_async is False
|
| 67 |
+
assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
|
| 68 |
+
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
| 69 |
+
assert "flag" in tool.parameters["properties"]
|
| 70 |
|
| 71 |
+
def test_add_invalid_tool(self):
|
| 72 |
+
manager = ToolManager()
|
| 73 |
+
with pytest.raises(AttributeError):
|
| 74 |
+
manager.add_tool(1) # type: ignore
|
| 75 |
|
| 76 |
+
def test_add_lambda(self):
|
| 77 |
+
manager = ToolManager()
|
| 78 |
+
tool = manager.add_tool(lambda x: x, name="my_tool")
|
| 79 |
+
assert tool.name == "my_tool"
|
| 80 |
|
| 81 |
+
def test_add_lambda_with_no_name(self):
|
| 82 |
+
manager = ToolManager()
|
| 83 |
+
with pytest.raises(
|
| 84 |
+
ValueError, match="You must provide a name for lambda functions"
|
| 85 |
+
):
|
| 86 |
+
manager.add_tool(lambda x: x)
|
| 87 |
|
| 88 |
+
def test_warn_on_duplicate_tools(self, caplog):
|
| 89 |
+
"""Test warning on duplicate tools."""
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
+
def f(x: int) -> int:
|
| 92 |
+
return x
|
|
|
|
| 93 |
|
| 94 |
+
manager = ToolManager()
|
| 95 |
+
manager.add_tool(f)
|
| 96 |
+
with caplog.at_level(logging.WARNING):
|
| 97 |
+
manager.add_tool(f)
|
| 98 |
+
assert "Tool already exists: f" in caplog.text
|
| 99 |
|
| 100 |
+
def test_disable_warn_on_duplicate_tools(self, caplog):
|
| 101 |
+
"""Test disabling warning on duplicate tools."""
|
| 102 |
|
| 103 |
+
def f(x: int) -> int:
|
| 104 |
+
return x
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
manager = ToolManager()
|
| 107 |
+
manager.add_tool(f)
|
| 108 |
+
manager.warn_on_duplicate_tools = False
|
| 109 |
+
with caplog.at_level(logging.WARNING):
|
| 110 |
+
manager.add_tool(f)
|
| 111 |
+
assert "Tool already exists: f" not in caplog.text
|
| 112 |
|
| 113 |
+
|
| 114 |
+
class TestCallTools:
|
| 115 |
+
@pytest.mark.anyio
|
| 116 |
+
async def test_call_tool(self):
|
| 117 |
+
def add(a: int, b: int) -> int:
|
| 118 |
+
"""Add two numbers."""
|
| 119 |
+
return a + b
|
| 120 |
+
|
| 121 |
+
manager = ToolManager()
|
| 122 |
+
manager.add_tool(add)
|
| 123 |
+
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
| 124 |
+
assert result == 3
|
| 125 |
+
|
| 126 |
+
@pytest.mark.anyio
|
| 127 |
+
async def test_call_async_tool(self):
|
| 128 |
+
async def double(n: int) -> int:
|
| 129 |
+
"""Double a number."""
|
| 130 |
+
return n * 2
|
| 131 |
+
|
| 132 |
+
manager = ToolManager()
|
| 133 |
+
manager.add_tool(double)
|
| 134 |
+
result = await manager.call_tool("double", {"n": 5})
|
| 135 |
+
assert result == 10
|
| 136 |
+
|
| 137 |
+
@pytest.mark.anyio
|
| 138 |
+
async def test_call_tool_with_default_args(self):
|
| 139 |
+
def add(a: int, b: int = 1) -> int:
|
| 140 |
+
"""Add two numbers."""
|
| 141 |
+
return a + b
|
| 142 |
+
|
| 143 |
+
manager = ToolManager()
|
| 144 |
+
manager.add_tool(add)
|
| 145 |
+
result = await manager.call_tool("add", {"a": 1})
|
| 146 |
+
assert result == 2
|
| 147 |
+
|
| 148 |
+
@pytest.mark.anyio
|
| 149 |
+
async def test_call_tool_with_missing_args(self):
|
| 150 |
+
def add(a: int, b: int) -> int:
|
| 151 |
+
"""Add two numbers."""
|
| 152 |
+
return a + b
|
| 153 |
+
|
| 154 |
+
manager = ToolManager()
|
| 155 |
+
manager.add_tool(add)
|
| 156 |
+
with pytest.raises(ToolError):
|
| 157 |
+
await manager.call_tool("add", {"a": 1})
|
| 158 |
+
|
| 159 |
+
@pytest.mark.anyio
|
| 160 |
+
async def test_call_unknown_tool(self):
|
| 161 |
+
manager = ToolManager()
|
| 162 |
+
with pytest.raises(ToolError):
|
| 163 |
+
await manager.call_tool("unknown", {"a": 1})
|
| 164 |
+
|
| 165 |
+
@pytest.mark.anyio
|
| 166 |
+
async def test_call_tool_with_list_int_input(self):
|
| 167 |
+
def sum_vals(vals: list[int]) -> int:
|
| 168 |
+
return sum(vals)
|
| 169 |
+
|
| 170 |
+
manager = ToolManager()
|
| 171 |
+
manager.add_tool(sum_vals)
|
| 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
|
| 175 |
+
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 176 |
+
assert result == 6
|
| 177 |
+
|
| 178 |
+
@pytest.mark.anyio
|
| 179 |
+
async def test_call_tool_with_list_str_or_str_input(self):
|
| 180 |
+
def concat_strs(vals: list[str] | str) -> str:
|
| 181 |
+
return vals if isinstance(vals, str) else "".join(vals)
|
| 182 |
+
|
| 183 |
+
manager = ToolManager()
|
| 184 |
+
manager.add_tool(concat_strs)
|
| 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"
|
| 188 |
+
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
|
| 189 |
+
assert result == "abc"
|
| 190 |
+
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 191 |
+
assert result == "a"
|
| 192 |
+
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
|
| 193 |
+
assert result == '"a"'
|
| 194 |
+
|
| 195 |
+
@pytest.mark.anyio
|
| 196 |
+
async def test_call_tool_with_complex_model(self):
|
| 197 |
+
from fastmcp import Context
|
| 198 |
+
|
| 199 |
+
class MyShrimpTank(BaseModel):
|
| 200 |
+
class Shrimp(BaseModel):
|
| 201 |
+
name: str
|
| 202 |
+
|
| 203 |
+
shrimp: list[Shrimp]
|
| 204 |
+
x: None
|
| 205 |
+
|
| 206 |
+
def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]:
|
| 207 |
+
return [x.name for x in tank.shrimp]
|
| 208 |
+
|
| 209 |
+
manager = ToolManager()
|
| 210 |
+
manager.add_tool(name_shrimp)
|
| 211 |
+
result = await manager.call_tool(
|
| 212 |
+
"name_shrimp",
|
| 213 |
+
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
|
| 214 |
+
)
|
| 215 |
+
assert result == ["rex", "gertrude"]
|
| 216 |
+
result = await manager.call_tool(
|
| 217 |
+
"name_shrimp",
|
| 218 |
+
{"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
|
| 219 |
+
)
|
| 220 |
+
assert result == ["rex", "gertrude"]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class TestToolSchema:
|
| 224 |
+
@pytest.mark.anyio
|
| 225 |
+
async def test_context_arg_excluded_from_schema(self):
|
| 226 |
+
from fastmcp import Context
|
| 227 |
+
|
| 228 |
+
def something(a: int, ctx: Context) -> int:
|
| 229 |
+
return a
|
| 230 |
+
|
| 231 |
+
manager = ToolManager()
|
| 232 |
+
tool = manager.add_tool(something)
|
| 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
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class TestContextHandling:
|
| 239 |
+
"""Test context handling in the tool manager."""
|
| 240 |
+
|
| 241 |
+
def test_context_parameter_detection(self):
|
| 242 |
+
"""Test that context parameters are properly detected in
|
| 243 |
+
Tool.from_function()."""
|
| 244 |
+
from fastmcp import Context
|
| 245 |
+
|
| 246 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 247 |
+
return str(x)
|
| 248 |
+
|
| 249 |
+
manager = ToolManager()
|
| 250 |
+
tool = manager.add_tool(tool_with_context)
|
| 251 |
+
assert tool.context_kwarg == "ctx"
|
| 252 |
+
|
| 253 |
+
def tool_without_context(x: int) -> str:
|
| 254 |
+
return str(x)
|
| 255 |
+
|
| 256 |
+
tool = manager.add_tool(tool_without_context)
|
| 257 |
+
assert tool.context_kwarg is None
|
| 258 |
+
|
| 259 |
+
@pytest.mark.anyio
|
| 260 |
+
async def test_context_injection(self):
|
| 261 |
+
"""Test that context is properly injected during tool execution."""
|
| 262 |
+
from fastmcp import Context, FastMCP
|
| 263 |
+
|
| 264 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 265 |
+
assert isinstance(ctx, Context)
|
| 266 |
+
return str(x)
|
| 267 |
+
|
| 268 |
+
manager = ToolManager()
|
| 269 |
+
manager.add_tool(tool_with_context)
|
| 270 |
+
|
| 271 |
+
mcp = FastMCP()
|
| 272 |
+
ctx = mcp.get_context()
|
| 273 |
+
result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
|
| 274 |
+
assert result == "42"
|
| 275 |
+
|
| 276 |
+
@pytest.mark.anyio
|
| 277 |
+
async def test_context_injection_async(self):
|
| 278 |
+
"""Test that context is properly injected in async tools."""
|
| 279 |
+
from fastmcp import Context, FastMCP
|
| 280 |
+
|
| 281 |
+
async def async_tool(x: int, ctx: Context) -> str:
|
| 282 |
+
assert isinstance(ctx, Context)
|
| 283 |
+
return str(x)
|
| 284 |
+
|
| 285 |
+
manager = ToolManager()
|
| 286 |
+
manager.add_tool(async_tool)
|
| 287 |
+
|
| 288 |
+
mcp = FastMCP()
|
| 289 |
+
ctx = mcp.get_context()
|
| 290 |
+
result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
|
| 291 |
+
assert result == "42"
|
| 292 |
+
|
| 293 |
+
@pytest.mark.anyio
|
| 294 |
+
async def test_context_optional(self):
|
| 295 |
+
"""Test that context is optional when calling tools."""
|
| 296 |
+
from fastmcp import Context
|
| 297 |
+
|
| 298 |
+
def tool_with_context(x: int, ctx: Context | None = None) -> str:
|
| 299 |
+
return str(x)
|
| 300 |
+
|
| 301 |
+
manager = ToolManager()
|
| 302 |
+
manager.add_tool(tool_with_context)
|
| 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"
|
| 306 |
+
|
| 307 |
+
@pytest.mark.anyio
|
| 308 |
+
async def test_context_error_handling(self):
|
| 309 |
+
"""Test error handling when context injection fails."""
|
| 310 |
+
from fastmcp import Context, FastMCP
|
| 311 |
+
|
| 312 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 313 |
+
raise ValueError("Test error")
|
| 314 |
+
|
| 315 |
+
manager = ToolManager()
|
| 316 |
+
manager.add_tool(tool_with_context)
|
| 317 |
+
|
| 318 |
+
mcp = FastMCP()
|
| 319 |
+
ctx = mcp.get_context()
|
| 320 |
+
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
|
| 321 |
+
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class TestImportTools:
|
| 325 |
+
def test_import_tools(self):
|
| 326 |
+
"""Test importing tools from one manager to another with a prefix."""
|
| 327 |
+
# Setup source manager with tools
|
| 328 |
+
source_manager = ToolManager()
|
| 329 |
+
|
| 330 |
+
# Create some test tools
|
| 331 |
+
def tool1_fn():
|
| 332 |
+
return "Tool 1 result"
|
| 333 |
+
|
| 334 |
+
def tool2_fn():
|
| 335 |
+
return "Tool 2 result"
|
| 336 |
+
|
| 337 |
+
# Add tools to source manager
|
| 338 |
+
source_manager.add_tool(tool1_fn, name="get_data", description="Get some data")
|
| 339 |
+
source_manager.add_tool(
|
| 340 |
+
tool2_fn, name="process_data", description="Process the data"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# Create target manager
|
| 344 |
+
target_manager = ToolManager()
|
| 345 |
+
|
| 346 |
+
# Import tools from source to target
|
| 347 |
+
prefix = "source/"
|
| 348 |
+
target_manager.import_tools(source_manager, prefix)
|
| 349 |
+
|
| 350 |
+
# Verify tools were imported with prefixes
|
| 351 |
+
assert "source/get_data" in target_manager._tools
|
| 352 |
+
assert "source/process_data" in target_manager._tools
|
| 353 |
+
|
| 354 |
+
# Verify the original tools still exist in source manager
|
| 355 |
+
assert "get_data" in source_manager._tools
|
| 356 |
+
assert "process_data" in source_manager._tools
|
| 357 |
+
|
| 358 |
+
# Verify the imported tools have the correct descriptions
|
| 359 |
+
assert target_manager._tools["source/get_data"].description == "Get some data"
|
| 360 |
+
assert (
|
| 361 |
+
target_manager._tools["source/process_data"].description
|
| 362 |
+
== "Process the data"
|
| 363 |
+
)
|
| 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.__name__ == tool1_fn.__name__
|
| 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."""
|
| 375 |
+
# Setup source and target managers
|
| 376 |
+
source_manager = ToolManager()
|
| 377 |
+
target_manager = ToolManager()
|
| 378 |
+
|
| 379 |
+
# Add the same tool name to both managers
|
| 380 |
+
def source_fn():
|
| 381 |
+
return "Source result"
|
| 382 |
+
|
| 383 |
+
def target_fn():
|
| 384 |
+
return "Target result"
|
| 385 |
+
|
| 386 |
+
source_manager.add_tool(source_fn, name="common_tool")
|
| 387 |
+
target_manager.add_tool(
|
| 388 |
+
target_fn, name="source/common_tool"
|
| 389 |
+
) # Pre-create with the prefixed name
|
| 390 |
+
|
| 391 |
+
# Import tools from source to target
|
| 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."""
|
| 402 |
+
# Setup source managers
|
| 403 |
+
weather_manager = ToolManager()
|
| 404 |
+
news_manager = ToolManager()
|
| 405 |
+
|
| 406 |
+
# Add tools to source managers
|
| 407 |
+
def forecast_fn():
|
| 408 |
+
return "Weather forecast"
|
| 409 |
+
|
| 410 |
+
def headlines_fn():
|
| 411 |
+
return "News headlines"
|
| 412 |
+
|
| 413 |
+
weather_manager.add_tool(forecast_fn, name="forecast")
|
| 414 |
+
news_manager.add_tool(headlines_fn, name="headlines")
|
| 415 |
+
|
| 416 |
+
# Create target manager and import from both sources
|
| 417 |
+
main_manager = ToolManager()
|
| 418 |
+
main_manager.import_tools(weather_manager, "weather/")
|
| 419 |
+
main_manager.import_tools(news_manager, "news/")
|
| 420 |
+
|
| 421 |
+
# Verify tools were imported with correct prefixes
|
| 422 |
+
assert "weather/forecast" in main_manager._tools
|
| 423 |
+
assert "news/headlines" in main_manager._tools
|
| 424 |
+
|
| 425 |
+
# Verify the tools are accessible and functioning
|
| 426 |
+
assert (
|
| 427 |
+
main_manager._tools["weather/forecast"].fn.__name__ == forecast_fn.__name__
|
| 428 |
+
)
|
| 429 |
+
assert (
|
| 430 |
+
main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
|
| 431 |
+
)
|
tests/tools/tool_manager.py
DELETED
|
@@ -1,104 +0,0 @@
|
|
| 1 |
-
from fastmcp.tools.tool_manager import ToolManager
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
def test_import_tools():
|
| 5 |
-
"""Test importing tools from one manager to another with a prefix."""
|
| 6 |
-
# Setup source manager with tools
|
| 7 |
-
source_manager = ToolManager()
|
| 8 |
-
|
| 9 |
-
# Create some test tools
|
| 10 |
-
def tool1_fn():
|
| 11 |
-
return "Tool 1 result"
|
| 12 |
-
|
| 13 |
-
def tool2_fn():
|
| 14 |
-
return "Tool 2 result"
|
| 15 |
-
|
| 16 |
-
# Add tools to source manager
|
| 17 |
-
source_manager.add_tool(tool1_fn, name="get_data", description="Get some data")
|
| 18 |
-
source_manager.add_tool(
|
| 19 |
-
tool2_fn, name="process_data", description="Process the data"
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
# Create target manager
|
| 23 |
-
target_manager = ToolManager()
|
| 24 |
-
|
| 25 |
-
# Import tools from source to target
|
| 26 |
-
prefix = "source"
|
| 27 |
-
target_manager.import_tools(source_manager, prefix)
|
| 28 |
-
|
| 29 |
-
# Verify tools were imported with prefixes
|
| 30 |
-
assert "source:get_data" in target_manager._tools
|
| 31 |
-
assert "source:process_data" in target_manager._tools
|
| 32 |
-
|
| 33 |
-
# Verify the original tools still exist in source manager
|
| 34 |
-
assert "get_data" in source_manager._tools
|
| 35 |
-
assert "process_data" in source_manager._tools
|
| 36 |
-
|
| 37 |
-
# Verify the imported tools have the correct descriptions
|
| 38 |
-
assert target_manager._tools["source:get_data"].description == "Get some data"
|
| 39 |
-
assert (
|
| 40 |
-
target_manager._tools["source:process_data"].description == "Process the data"
|
| 41 |
-
)
|
| 42 |
-
|
| 43 |
-
# Verify the tool functions were properly copied
|
| 44 |
-
# We can't directly compare functions, so we'll check their __name__ attribute
|
| 45 |
-
assert target_manager._tools["source:get_data"].fn.__name__ == tool1_fn.__name__
|
| 46 |
-
assert target_manager._tools["source:process_data"].fn.__name__ == tool2_fn.__name__
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def test_import_tools_duplicate_warning(caplog):
|
| 50 |
-
"""Test that warning is logged when importing a tool with a name that already exists."""
|
| 51 |
-
# Setup source and target managers
|
| 52 |
-
source_manager = ToolManager()
|
| 53 |
-
target_manager = ToolManager(warn_on_duplicate_tools=True)
|
| 54 |
-
|
| 55 |
-
# Add the same tool name to both managers
|
| 56 |
-
def source_fn():
|
| 57 |
-
return "Source result"
|
| 58 |
-
|
| 59 |
-
def target_fn():
|
| 60 |
-
return "Target result"
|
| 61 |
-
|
| 62 |
-
source_manager.add_tool(source_fn, name="common_tool")
|
| 63 |
-
target_manager.add_tool(
|
| 64 |
-
target_fn, name="source:common_tool"
|
| 65 |
-
) # Pre-create with the prefixed name
|
| 66 |
-
|
| 67 |
-
# Import tools from source to target
|
| 68 |
-
target_manager.import_tools(source_manager, "source")
|
| 69 |
-
|
| 70 |
-
# Verify a warning was logged
|
| 71 |
-
assert any("already exists" in record.message for record in caplog.records)
|
| 72 |
-
|
| 73 |
-
# The original tool in the target manager should be preserved
|
| 74 |
-
assert target_manager._tools["source:common_tool"].fn.__name__ == target_fn.__name__
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def test_import_tools_with_multiple_prefixes():
|
| 78 |
-
"""Test importing tools from multiple managers with different prefixes."""
|
| 79 |
-
# Setup source managers
|
| 80 |
-
weather_manager = ToolManager()
|
| 81 |
-
news_manager = ToolManager()
|
| 82 |
-
|
| 83 |
-
# Add tools to source managers
|
| 84 |
-
def forecast_fn():
|
| 85 |
-
return "Weather forecast"
|
| 86 |
-
|
| 87 |
-
def headlines_fn():
|
| 88 |
-
return "News headlines"
|
| 89 |
-
|
| 90 |
-
weather_manager.add_tool(forecast_fn, name="forecast")
|
| 91 |
-
news_manager.add_tool(headlines_fn, name="headlines")
|
| 92 |
-
|
| 93 |
-
# Create target manager and import from both sources
|
| 94 |
-
main_manager = ToolManager()
|
| 95 |
-
main_manager.import_tools(weather_manager, "weather")
|
| 96 |
-
main_manager.import_tools(news_manager, "news")
|
| 97 |
-
|
| 98 |
-
# Verify tools were imported with correct prefixes
|
| 99 |
-
assert "weather:forecast" in main_manager._tools
|
| 100 |
-
assert "news:headlines" in main_manager._tools
|
| 101 |
-
|
| 102 |
-
# Verify the tools are accessible and functioning
|
| 103 |
-
assert main_manager._tools["weather:forecast"].fn.__name__ == forecast_fn.__name__
|
| 104 |
-
assert main_manager._tools["news:headlines"].fn.__name__ == headlines_fn.__name__
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/utilities/__init__.py
ADDED
|
File without changes
|
tests/utilities/test_func_metadata.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated
|
| 2 |
+
|
| 3 |
+
import annotated_types
|
| 4 |
+
import pytest
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
from fastmcp.utilities.func_metadata import func_metadata
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SomeInputModelA(BaseModel):
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SomeInputModelB(BaseModel):
|
| 15 |
+
class InnerModel(BaseModel):
|
| 16 |
+
x: int
|
| 17 |
+
|
| 18 |
+
how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
|
| 19 |
+
ok: InnerModel
|
| 20 |
+
y: None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def complex_arguments_fn(
|
| 24 |
+
an_int: int,
|
| 25 |
+
must_be_none: None,
|
| 26 |
+
must_be_none_dumb_annotation: Annotated[None, "blah"],
|
| 27 |
+
list_of_ints: list[int],
|
| 28 |
+
# list[str] | str is an interesting case because if it comes in as JSON like
|
| 29 |
+
# "[\"a\", \"b\"]" then it will be naively parsed as a string.
|
| 30 |
+
list_str_or_str: list[str] | str,
|
| 31 |
+
an_int_annotated_with_field: Annotated[
|
| 32 |
+
int, Field(description="An int with a field")
|
| 33 |
+
],
|
| 34 |
+
an_int_annotated_with_field_and_others: Annotated[
|
| 35 |
+
int,
|
| 36 |
+
str, # Should be ignored, really
|
| 37 |
+
Field(description="An int with a field"),
|
| 38 |
+
annotated_types.Gt(1),
|
| 39 |
+
],
|
| 40 |
+
an_int_annotated_with_junk: Annotated[
|
| 41 |
+
int,
|
| 42 |
+
"123",
|
| 43 |
+
456,
|
| 44 |
+
],
|
| 45 |
+
field_with_default_via_field_annotation_before_nondefault_arg: Annotated[
|
| 46 |
+
int, Field(1)
|
| 47 |
+
],
|
| 48 |
+
unannotated,
|
| 49 |
+
my_model_a: SomeInputModelA,
|
| 50 |
+
my_model_a_forward_ref: "SomeInputModelA",
|
| 51 |
+
my_model_b: SomeInputModelB,
|
| 52 |
+
an_int_annotated_with_field_default: Annotated[
|
| 53 |
+
int,
|
| 54 |
+
Field(1, description="An int with a field"),
|
| 55 |
+
],
|
| 56 |
+
unannotated_with_default=5,
|
| 57 |
+
my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
|
| 58 |
+
an_int_with_default: int = 1,
|
| 59 |
+
must_be_none_with_default: None = None,
|
| 60 |
+
an_int_with_equals_field: int = Field(1, ge=0),
|
| 61 |
+
int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
|
| 62 |
+
) -> str:
|
| 63 |
+
_ = (
|
| 64 |
+
an_int,
|
| 65 |
+
must_be_none,
|
| 66 |
+
must_be_none_dumb_annotation,
|
| 67 |
+
list_of_ints,
|
| 68 |
+
list_str_or_str,
|
| 69 |
+
an_int_annotated_with_field,
|
| 70 |
+
an_int_annotated_with_field_and_others,
|
| 71 |
+
an_int_annotated_with_junk,
|
| 72 |
+
field_with_default_via_field_annotation_before_nondefault_arg,
|
| 73 |
+
unannotated,
|
| 74 |
+
an_int_annotated_with_field_default,
|
| 75 |
+
unannotated_with_default,
|
| 76 |
+
my_model_a,
|
| 77 |
+
my_model_a_forward_ref,
|
| 78 |
+
my_model_b,
|
| 79 |
+
my_model_a_with_default,
|
| 80 |
+
an_int_with_default,
|
| 81 |
+
must_be_none_with_default,
|
| 82 |
+
an_int_with_equals_field,
|
| 83 |
+
int_annotated_with_default,
|
| 84 |
+
)
|
| 85 |
+
return "ok!"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@pytest.mark.anyio
|
| 89 |
+
async def test_complex_function_runtime_arg_validation_non_json():
|
| 90 |
+
"""Test that basic non-JSON arguments are validated correctly"""
|
| 91 |
+
meta = func_metadata(complex_arguments_fn)
|
| 92 |
+
|
| 93 |
+
# Test with minimum required arguments
|
| 94 |
+
result = await meta.call_fn_with_arg_validation(
|
| 95 |
+
complex_arguments_fn,
|
| 96 |
+
fn_is_async=False,
|
| 97 |
+
arguments_to_validate={
|
| 98 |
+
"an_int": 1,
|
| 99 |
+
"must_be_none": None,
|
| 100 |
+
"must_be_none_dumb_annotation": None,
|
| 101 |
+
"list_of_ints": [1, 2, 3],
|
| 102 |
+
"list_str_or_str": "hello",
|
| 103 |
+
"an_int_annotated_with_field": 42,
|
| 104 |
+
"an_int_annotated_with_field_and_others": 5,
|
| 105 |
+
"an_int_annotated_with_junk": 100,
|
| 106 |
+
"unannotated": "test",
|
| 107 |
+
"my_model_a": {},
|
| 108 |
+
"my_model_a_forward_ref": {},
|
| 109 |
+
"my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
|
| 110 |
+
},
|
| 111 |
+
arguments_to_pass_directly=None,
|
| 112 |
+
)
|
| 113 |
+
assert result == "ok!"
|
| 114 |
+
|
| 115 |
+
# Test with invalid types
|
| 116 |
+
with pytest.raises(ValueError):
|
| 117 |
+
await meta.call_fn_with_arg_validation(
|
| 118 |
+
complex_arguments_fn,
|
| 119 |
+
fn_is_async=False,
|
| 120 |
+
arguments_to_validate={"an_int": "not an int"},
|
| 121 |
+
arguments_to_pass_directly=None,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@pytest.mark.anyio
|
| 126 |
+
async def test_complex_function_runtime_arg_validation_with_json():
|
| 127 |
+
"""Test that JSON string arguments are parsed and validated correctly"""
|
| 128 |
+
meta = func_metadata(complex_arguments_fn)
|
| 129 |
+
|
| 130 |
+
result = await meta.call_fn_with_arg_validation(
|
| 131 |
+
complex_arguments_fn,
|
| 132 |
+
fn_is_async=False,
|
| 133 |
+
arguments_to_validate={
|
| 134 |
+
"an_int": 1,
|
| 135 |
+
"must_be_none": None,
|
| 136 |
+
"must_be_none_dumb_annotation": None,
|
| 137 |
+
"list_of_ints": "[1, 2, 3]", # JSON string
|
| 138 |
+
"list_str_or_str": '["a", "b", "c"]', # JSON string
|
| 139 |
+
"an_int_annotated_with_field": 42,
|
| 140 |
+
"an_int_annotated_with_field_and_others": "5", # JSON string
|
| 141 |
+
"an_int_annotated_with_junk": 100,
|
| 142 |
+
"unannotated": "test",
|
| 143 |
+
"my_model_a": "{}", # JSON string
|
| 144 |
+
"my_model_a_forward_ref": "{}", # JSON string
|
| 145 |
+
"my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
|
| 146 |
+
},
|
| 147 |
+
arguments_to_pass_directly=None,
|
| 148 |
+
)
|
| 149 |
+
assert result == "ok!"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_str_vs_list_str():
|
| 153 |
+
"""Test handling of string vs list[str] type annotations.
|
| 154 |
+
|
| 155 |
+
This is tricky as '"hello"' can be parsed as a JSON string or a Python string.
|
| 156 |
+
We want to make sure it's kept as a python string.
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
def func_with_str_types(str_or_list: str | list[str]):
|
| 160 |
+
return str_or_list
|
| 161 |
+
|
| 162 |
+
meta = func_metadata(func_with_str_types)
|
| 163 |
+
|
| 164 |
+
# Test string input for union type
|
| 165 |
+
result = meta.pre_parse_json({"str_or_list": "hello"})
|
| 166 |
+
assert result["str_or_list"] == "hello"
|
| 167 |
+
|
| 168 |
+
# Test string input that contains valid JSON for union type
|
| 169 |
+
# We want to see here that the JSON-vali string is NOT parsed as JSON, but rather
|
| 170 |
+
# kept as a raw string
|
| 171 |
+
result = meta.pre_parse_json({"str_or_list": '"hello"'})
|
| 172 |
+
assert result["str_or_list"] == '"hello"'
|
| 173 |
+
|
| 174 |
+
# Test list input for union type
|
| 175 |
+
result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'})
|
| 176 |
+
assert result["str_or_list"] == ["hello", "world"]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_skip_names():
|
| 180 |
+
"""Test that skipped parameters are not included in the model"""
|
| 181 |
+
|
| 182 |
+
def func_with_many_params(
|
| 183 |
+
keep_this: int, skip_this: str, also_keep: float, also_skip: bool
|
| 184 |
+
):
|
| 185 |
+
return keep_this, skip_this, also_keep, also_skip
|
| 186 |
+
|
| 187 |
+
# Skip some parameters
|
| 188 |
+
meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"])
|
| 189 |
+
|
| 190 |
+
# Check model fields
|
| 191 |
+
assert "keep_this" in meta.arg_model.model_fields
|
| 192 |
+
assert "also_keep" in meta.arg_model.model_fields
|
| 193 |
+
assert "skip_this" not in meta.arg_model.model_fields
|
| 194 |
+
assert "also_skip" not in meta.arg_model.model_fields
|
| 195 |
+
|
| 196 |
+
# Validate that we can call with only non-skipped parameters
|
| 197 |
+
model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
|
| 198 |
+
assert model.keep_this == 1 # type: ignore
|
| 199 |
+
assert model.also_keep == 2.5 # type: ignore
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@pytest.mark.anyio
|
| 203 |
+
async def test_lambda_function():
|
| 204 |
+
"""Test lambda function schema and validation"""
|
| 205 |
+
fn = lambda x, y=5: x # noqa: E731
|
| 206 |
+
meta = func_metadata(lambda x, y=5: x)
|
| 207 |
+
|
| 208 |
+
# Test schema
|
| 209 |
+
assert meta.arg_model.model_json_schema() == {
|
| 210 |
+
"properties": {
|
| 211 |
+
"x": {"title": "x", "type": "string"},
|
| 212 |
+
"y": {"default": 5, "title": "y", "type": "string"},
|
| 213 |
+
},
|
| 214 |
+
"required": ["x"],
|
| 215 |
+
"title": "<lambda>Arguments",
|
| 216 |
+
"type": "object",
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
async def check_call(args):
|
| 220 |
+
return await meta.call_fn_with_arg_validation(
|
| 221 |
+
fn,
|
| 222 |
+
fn_is_async=False,
|
| 223 |
+
arguments_to_validate=args,
|
| 224 |
+
arguments_to_pass_directly=None,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# Basic calls
|
| 228 |
+
assert await check_call({"x": "hello"}) == "hello"
|
| 229 |
+
assert await check_call({"x": "hello", "y": "world"}) == "hello"
|
| 230 |
+
assert await check_call({"x": '"hello"'}) == '"hello"'
|
| 231 |
+
|
| 232 |
+
# Missing required arg
|
| 233 |
+
with pytest.raises(ValueError):
|
| 234 |
+
await check_call({"y": "world"})
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def test_complex_function_json_schema():
|
| 238 |
+
"""Test JSON schema generation for complex function arguments.
|
| 239 |
+
|
| 240 |
+
Note: Different versions of pydantic output slightly different
|
| 241 |
+
JSON Schema formats for model fields with defaults. The format changed in 2.9.0:
|
| 242 |
+
|
| 243 |
+
1. Before 2.9.0:
|
| 244 |
+
{
|
| 245 |
+
"allOf": [{"$ref": "#/$defs/Model"}],
|
| 246 |
+
"default": {}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
2. Since 2.9.0:
|
| 250 |
+
{
|
| 251 |
+
"$ref": "#/$defs/Model",
|
| 252 |
+
"default": {}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
Both formats are valid and functionally equivalent. This test accepts either format
|
| 256 |
+
to ensure compatibility across our supported pydantic versions.
|
| 257 |
+
|
| 258 |
+
This change in format does not affect runtime behavior since:
|
| 259 |
+
1. Both schemas validate the same way
|
| 260 |
+
2. The actual model classes and validation logic are unchanged
|
| 261 |
+
3. func_metadata uses model_validate/model_dump, not the schema directly
|
| 262 |
+
"""
|
| 263 |
+
meta = func_metadata(complex_arguments_fn)
|
| 264 |
+
actual_schema = meta.arg_model.model_json_schema()
|
| 265 |
+
|
| 266 |
+
# Create a copy of the actual schema to normalize
|
| 267 |
+
normalized_schema = actual_schema.copy()
|
| 268 |
+
|
| 269 |
+
# Normalize the my_model_a_with_default field to handle both pydantic formats
|
| 270 |
+
if "allOf" in actual_schema["properties"]["my_model_a_with_default"]:
|
| 271 |
+
normalized_schema["properties"]["my_model_a_with_default"] = {
|
| 272 |
+
"$ref": "#/$defs/SomeInputModelA",
|
| 273 |
+
"default": {},
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
assert normalized_schema == {
|
| 277 |
+
"$defs": {
|
| 278 |
+
"InnerModel": {
|
| 279 |
+
"properties": {"x": {"title": "X", "type": "integer"}},
|
| 280 |
+
"required": ["x"],
|
| 281 |
+
"title": "InnerModel",
|
| 282 |
+
"type": "object",
|
| 283 |
+
},
|
| 284 |
+
"SomeInputModelA": {
|
| 285 |
+
"properties": {},
|
| 286 |
+
"title": "SomeInputModelA",
|
| 287 |
+
"type": "object",
|
| 288 |
+
},
|
| 289 |
+
"SomeInputModelB": {
|
| 290 |
+
"properties": {
|
| 291 |
+
"how_many_shrimp": {
|
| 292 |
+
"description": "How many shrimp in the tank???",
|
| 293 |
+
"title": "How Many Shrimp",
|
| 294 |
+
"type": "integer",
|
| 295 |
+
},
|
| 296 |
+
"ok": {"$ref": "#/$defs/InnerModel"},
|
| 297 |
+
"y": {"title": "Y", "type": "null"},
|
| 298 |
+
},
|
| 299 |
+
"required": ["how_many_shrimp", "ok", "y"],
|
| 300 |
+
"title": "SomeInputModelB",
|
| 301 |
+
"type": "object",
|
| 302 |
+
},
|
| 303 |
+
},
|
| 304 |
+
"properties": {
|
| 305 |
+
"an_int": {"title": "An Int", "type": "integer"},
|
| 306 |
+
"must_be_none": {"title": "Must Be None", "type": "null"},
|
| 307 |
+
"must_be_none_dumb_annotation": {
|
| 308 |
+
"title": "Must Be None Dumb Annotation",
|
| 309 |
+
"type": "null",
|
| 310 |
+
},
|
| 311 |
+
"list_of_ints": {
|
| 312 |
+
"items": {"type": "integer"},
|
| 313 |
+
"title": "List Of Ints",
|
| 314 |
+
"type": "array",
|
| 315 |
+
},
|
| 316 |
+
"list_str_or_str": {
|
| 317 |
+
"anyOf": [
|
| 318 |
+
{"items": {"type": "string"}, "type": "array"},
|
| 319 |
+
{"type": "string"},
|
| 320 |
+
],
|
| 321 |
+
"title": "List Str Or Str",
|
| 322 |
+
},
|
| 323 |
+
"an_int_annotated_with_field": {
|
| 324 |
+
"description": "An int with a field",
|
| 325 |
+
"title": "An Int Annotated With Field",
|
| 326 |
+
"type": "integer",
|
| 327 |
+
},
|
| 328 |
+
"an_int_annotated_with_field_and_others": {
|
| 329 |
+
"description": "An int with a field",
|
| 330 |
+
"exclusiveMinimum": 1,
|
| 331 |
+
"title": "An Int Annotated With Field And Others",
|
| 332 |
+
"type": "integer",
|
| 333 |
+
},
|
| 334 |
+
"an_int_annotated_with_junk": {
|
| 335 |
+
"title": "An Int Annotated With Junk",
|
| 336 |
+
"type": "integer",
|
| 337 |
+
},
|
| 338 |
+
"field_with_default_via_field_annotation_before_nondefault_arg": {
|
| 339 |
+
"default": 1,
|
| 340 |
+
"title": "Field With Default Via Field Annotation Before Nondefault Arg",
|
| 341 |
+
"type": "integer",
|
| 342 |
+
},
|
| 343 |
+
"unannotated": {"title": "unannotated", "type": "string"},
|
| 344 |
+
"my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
|
| 345 |
+
"my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
|
| 346 |
+
"my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
|
| 347 |
+
"an_int_annotated_with_field_default": {
|
| 348 |
+
"default": 1,
|
| 349 |
+
"description": "An int with a field",
|
| 350 |
+
"title": "An Int Annotated With Field Default",
|
| 351 |
+
"type": "integer",
|
| 352 |
+
},
|
| 353 |
+
"unannotated_with_default": {
|
| 354 |
+
"default": 5,
|
| 355 |
+
"title": "unannotated_with_default",
|
| 356 |
+
"type": "string",
|
| 357 |
+
},
|
| 358 |
+
"my_model_a_with_default": {
|
| 359 |
+
"$ref": "#/$defs/SomeInputModelA",
|
| 360 |
+
"default": {},
|
| 361 |
+
},
|
| 362 |
+
"an_int_with_default": {
|
| 363 |
+
"default": 1,
|
| 364 |
+
"title": "An Int With Default",
|
| 365 |
+
"type": "integer",
|
| 366 |
+
},
|
| 367 |
+
"must_be_none_with_default": {
|
| 368 |
+
"default": None,
|
| 369 |
+
"title": "Must Be None With Default",
|
| 370 |
+
"type": "null",
|
| 371 |
+
},
|
| 372 |
+
"an_int_with_equals_field": {
|
| 373 |
+
"default": 1,
|
| 374 |
+
"minimum": 0,
|
| 375 |
+
"title": "An Int With Equals Field",
|
| 376 |
+
"type": "integer",
|
| 377 |
+
},
|
| 378 |
+
"int_annotated_with_default": {
|
| 379 |
+
"default": 5,
|
| 380 |
+
"description": "hey",
|
| 381 |
+
"title": "Int Annotated With Default",
|
| 382 |
+
"type": "integer",
|
| 383 |
+
},
|
| 384 |
+
},
|
| 385 |
+
"required": [
|
| 386 |
+
"an_int",
|
| 387 |
+
"must_be_none",
|
| 388 |
+
"must_be_none_dumb_annotation",
|
| 389 |
+
"list_of_ints",
|
| 390 |
+
"list_str_or_str",
|
| 391 |
+
"an_int_annotated_with_field",
|
| 392 |
+
"an_int_annotated_with_field_and_others",
|
| 393 |
+
"an_int_annotated_with_junk",
|
| 394 |
+
"unannotated",
|
| 395 |
+
"my_model_a",
|
| 396 |
+
"my_model_a_forward_ref",
|
| 397 |
+
"my_model_b",
|
| 398 |
+
],
|
| 399 |
+
"title": "complex_arguments_fnArguments",
|
| 400 |
+
"type": "object",
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def test_str_vs_int():
|
| 405 |
+
"""
|
| 406 |
+
Test that string values are kept as strings even when they contain numbers,
|
| 407 |
+
while numbers are parsed correctly.
|
| 408 |
+
"""
|
| 409 |
+
|
| 410 |
+
def func_with_str_and_int(a: str, b: int):
|
| 411 |
+
return a
|
| 412 |
+
|
| 413 |
+
meta = func_metadata(func_with_str_and_int)
|
| 414 |
+
result = meta.pre_parse_json({"a": "123", "b": 123})
|
| 415 |
+
assert result["a"] == "123"
|
| 416 |
+
assert result["b"] == 123
|