Jeremiah Lowin commited on
Commit
8c74b9c
·
unverified ·
2 Parent(s): a038c6848f7143

Merge pull request #107 from jlowin/restore

Browse files
src/fastmcp/__init__.py CHANGED
@@ -1,12 +1,11 @@
1
  """FastMCP - An ergonomic MCP interface."""
2
 
3
  from importlib.metadata import version
4
- from fastmcp.server import FastMCP, Context
 
 
 
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
- import logging
2
 
3
- from mcp.server.fastmcp.prompts import PromptManager as BasePromptManager
4
 
5
- logger = logging.getLogger(__name__)
 
6
 
 
7
 
8
- class PromptManager(BasePromptManager):
9
- """
10
- Extended PromptManager that supports importing prompts from other managers.
11
- Adds ability to import prompts from other managers with prefixed names.
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__ = ["ResourceManager"]
 
 
 
 
 
 
 
 
 
 
 
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
- import logging
2
 
3
- from mcp.server.fastmcp.resources import (
4
- ResourceManager as BaseResourceManager,
5
- )
6
 
7
- logger = logging.getLogger(__name__)
8
 
 
 
 
9
 
10
- class ResourceManager(BaseResourceManager):
11
- """ResourceManager that adds methods to import resources from other managers."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 typing import Any
2
 
3
- import mcp.server.fastmcp
4
- from mcp.server.fastmcp.utilities.logging import get_logger
 
 
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(mcp.server.fastmcp.Context[ServerSessionT, LifespanContextT]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def __init__(
19
  self,
20
  *,
21
  request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None,
22
- fastmcp: mcp.server.fastmcp.FastMCP | None = None,
23
  **kwargs: Any,
24
  ):
25
- super().__init__(request_context=request_context, fastmcp=fastmcp, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from typing import TYPE_CHECKING, Any, Dict
2
 
3
- import mcp.server.fastmcp
4
- import mcp.types
5
 
6
- from fastmcp.prompts.prompt_manager import PromptManager
7
- from fastmcp.resources.resource_manager import ResourceManager
8
- from fastmcp.server.context import Context
9
- from fastmcp.tools.tool_manager import ToolManager
10
- from fastmcp.utilities.logging import get_logger
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- class FastMCP(mcp.server.fastmcp.FastMCP):
21
- def __init__(self, name: str | None = None, **settings: Any):
22
- # First initialize with default settings
23
- super().__init__(name=name or "FastMCP", **settings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- # Replace the default managers with our extended ones
 
 
 
 
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: Dict[str, "FastMCP"] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- def get_context(self) -> Context:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 typing import Literal
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: SettingsConfigDict = SettingsConfigDict(
16
- env_prefix="FASTMCP_",
17
  env_file=".env",
18
  extra="ignore",
19
  )
20
 
 
 
 
 
 
 
 
21
  debug: bool = False
22
- log_level: LOG_LEVEL = "INFO"
23
 
24
- # Client settings
25
- client_log_level: LOG_LEVEL | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 mcp.server.fastmcp.tools
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(mcp.server.fastmcp.tools.ToolManager):
10
- """
11
- Extended ToolManager that supports importing tools from other managers.
12
- Adds ability to import tools from other managers with prefixed names.
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: Optional[Union[str, Path]] = None,
16
- data: Optional[bytes] = None,
17
- format: Optional[str] = None,
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
- from mcp.server.fastmcp.prompts import Prompt
2
- from mcp.server.fastmcp.prompts.base import PromptArgument
3
 
 
 
4
  from fastmcp.prompts.prompt_manager import PromptManager
5
 
6
 
7
- def test_import_prompts():
8
- """Test importing prompts from one manager to another with a prefix."""
9
- # Setup source manager with prompts
10
- source_manager = PromptManager()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- # Create test prompts with proper function handlers
13
- async def summary_fn(**kwargs):
14
- return [{"role": "assistant", "content": f"Summary of: {kwargs.get('text')}"}]
15
 
16
- async def translate_fn(**kwargs):
17
- return [
18
- {
19
- "role": "assistant",
20
- "content": f"Translation to {kwargs.get('language')}: {kwargs.get('text')}",
21
- }
22
  ]
23
 
24
- summary_prompt = Prompt(
25
- name="summary",
26
- description="Generate a summary of text",
27
- arguments=[PromptArgument(name="text", description="Text to summarize")],
28
- fn=summary_fn,
29
- )
30
- source_manager._prompts["summary"] = summary_prompt
31
-
32
- translate_prompt = Prompt(
33
- name="translate",
34
- description="Translate text to another language",
35
- arguments=[
36
- PromptArgument(name="text", description="Text to translate"),
37
- PromptArgument(name="language", description="Target language"),
38
- ],
39
- fn=translate_fn,
40
- )
41
- source_manager._prompts["translate"] = translate_prompt
42
-
43
- # Create target manager
44
- target_manager = PromptManager()
45
-
46
- # Import prompts from source to target
47
- prefix = "nlp/"
48
- target_manager.import_prompts(source_manager, prefix)
49
-
50
- # Verify prompts were imported with prefixes
51
- assert "nlp/summary" in target_manager._prompts
52
- assert "nlp/translate" in target_manager._prompts
53
-
54
- # Verify the original prompts still exist in source manager
55
- assert "summary" in source_manager._prompts
56
- assert "translate" in source_manager._prompts
57
-
58
- # Verify the imported prompts have the correct properties
59
- assert target_manager._prompts["nlp/summary"].name == "summary"
60
- assert (
61
- target_manager._prompts["nlp/summary"].description
62
- == "Generate a summary of text"
63
- )
64
-
65
- assert target_manager._prompts["nlp/translate"].name == "translate"
66
- assert (
67
- target_manager._prompts["nlp/translate"].description
68
- == "Translate text to another language"
69
- )
70
-
71
- # Verify functions were properly copied
72
- if hasattr(target_manager._prompts["nlp/summary"], "fn"):
73
- assert target_manager._prompts["nlp/summary"].fn.__name__ == summary_fn.__name__
74
-
75
- if hasattr(target_manager._prompts["nlp/translate"], "fn"):
76
- assert (
77
- target_manager._prompts["nlp/translate"].fn.__name__
78
- == translate_fn.__name__
 
 
79
  )
 
80
 
 
 
81
 
82
- def test_import_prompts_with_duplicates():
83
- """Test handling of duplicate prompts during import."""
84
- # Setup source and target managers with same prompt names
85
- source_manager = PromptManager()
86
- target_manager = PromptManager()
87
-
88
- # Add the same prompt name to both managers with functions
89
- async def source_fn(**kwargs):
90
- return [{"role": "assistant", "content": "Source content"}]
91
-
92
- async def target_fn(**kwargs):
93
- return [{"role": "assistant", "content": "Target content"}]
94
-
95
- source_prompt = Prompt(
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["external/common"].fn.__name__ == source_fn.__name__
 
126
  )
127
 
 
 
 
 
 
128
 
129
- def test_import_prompts_with_nested_prefixes():
130
- """Test importing already prefixed prompts."""
131
- # Setup source manager with already prefixed prompts
132
- first_manager = PromptManager()
133
- second_manager = PromptManager()
134
- third_manager = PromptManager()
135
-
136
- # Add prompt to first manager with a function
137
- async def analyze_fn(**kwargs):
138
- return [{"role": "assistant", "content": f"Analysis of: {kwargs.get('text')}"}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- original_prompt = Prompt(
141
- name="analyze",
142
- description="Analyze text",
143
- arguments=[PromptArgument(name="text", description="Text to analyze")],
144
- fn=analyze_fn,
145
- )
146
- first_manager._prompts["analyze"] = original_prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
- # Import to second manager with prefix
149
- second_manager.import_prompts(first_manager, "text/")
150
 
151
- # Import from second to third with another prefix
152
- third_manager.import_prompts(second_manager, "ai/")
153
 
154
- # Verify the nested prefixing
155
- assert "text/analyze" in second_manager._prompts
156
- assert "ai/text/analyze" in third_manager._prompts
157
 
158
- # Verify the properties of the most nested prompt
159
- assert third_manager._prompts["ai/text/analyze"].name == "analyze"
160
- assert third_manager._prompts["ai/text/analyze"].description == "Analyze text"
161
 
162
- # Verify function was properly copied through multiple imports
163
- if hasattr(third_manager._prompts["ai/text/analyze"], "fn"):
164
- assert (
165
- third_manager._prompts["ai/text/analyze"].fn.__name__ == analyze_fn.__name__
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 mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate
2
- from pydantic.networks import AnyUrl
3
-
4
- from fastmcp.resources.resource_manager import ResourceManager
5
-
6
-
7
- def test_import_resources():
8
- """Test importing resources from one manager to another with a prefix."""
9
- # Setup source manager with resources
10
- source_manager = ResourceManager()
11
-
12
- # Create mock resource functions
13
- async def weather_fn():
14
- return "Weather data"
15
-
16
- async def traffic_fn():
17
- return "Traffic data"
18
-
19
- # Add resources to source manager
20
- weather_resource = FunctionResource(
21
- uri=AnyUrl("weather://forecast"),
22
- name="weather_forecast",
23
- description="Get weather forecast",
24
- mime_type="application/json",
25
- fn=weather_fn,
26
- )
27
- source_manager._resources["weather://forecast"] = weather_resource
28
-
29
- traffic_resource = FunctionResource(
30
- uri=AnyUrl("traffic://status"),
31
- name="traffic_status",
32
- description="Get traffic status",
33
- mime_type="application/json",
34
- fn=traffic_fn,
35
- )
36
- source_manager._resources["traffic://status"] = traffic_resource
37
-
38
- # Create target manager
39
- target_manager = ResourceManager()
40
-
41
- # Import resources from source to target
42
- prefix = "data+"
43
- target_manager.import_resources(source_manager, prefix)
44
-
45
- # Verify resources were imported with prefixes
46
- assert "data+weather://forecast" in target_manager._resources
47
- assert "data+traffic://status" in target_manager._resources
48
-
49
- # Verify the original resources still exist in source manager
50
- assert "weather://forecast" in source_manager._resources
51
- assert "traffic://status" in source_manager._resources
52
-
53
- # Verify the imported resources have the correct properties
54
- assert (
55
- target_manager._resources["data+weather://forecast"].name == "weather_forecast"
56
- )
57
- assert (
58
- target_manager._resources["data+weather://forecast"].description
59
- == "Get weather forecast"
60
- )
61
- assert (
62
- target_manager._resources["data+weather://forecast"].mime_type
63
- == "application/json"
64
- )
65
-
66
- assert target_manager._resources["data+traffic://status"].name == "traffic_status"
67
- assert (
68
- target_manager._resources["data+traffic://status"].description
69
- == "Get traffic status"
70
- )
71
- assert (
72
- target_manager._resources["data+traffic://status"].mime_type
73
- == "application/json"
74
- )
75
-
76
- # Since we're dealing with FunctionResource type, we can safely check function attributes
77
- assert isinstance(
78
- target_manager._resources["data+weather://forecast"], FunctionResource
79
- )
80
- assert isinstance(
81
- target_manager._resources["data+traffic://status"], FunctionResource
82
- )
83
-
84
- weather_resource = target_manager._resources["data+weather://forecast"]
85
- traffic_resource = target_manager._resources["data+traffic://status"]
86
-
87
- if hasattr(weather_resource, "fn") and hasattr(traffic_resource, "fn"):
88
- assert weather_resource.fn.__name__ == weather_fn.__name__
89
- assert traffic_resource.fn.__name__ == traffic_fn.__name__
90
-
91
-
92
- def test_import_templates():
93
- """Test importing resource templates from one manager to another with a prefix."""
94
- # Setup source manager with templates
95
- source_manager = ResourceManager()
96
-
97
- # Create mock template functions
98
- async def user_fn(**params):
99
- return f"User data for id {params.get('id')}"
100
-
101
- async def product_fn(**params):
102
- return f"Product data for id {params.get('id')}"
103
-
104
- # Add templates to source manager
105
- user_template = ResourceTemplate(
106
- uri_template="api://users/{id}",
107
- name="user_template",
108
- description="Get user by ID",
109
- mime_type="application/json",
110
- fn=user_fn,
111
- parameters={"id": {"type": "string", "description": "User ID"}},
112
- )
113
- source_manager._templates["api://users/{id}"] = user_template
114
-
115
- product_template = ResourceTemplate(
116
- uri_template="api://products/{id}",
117
- name="product_template",
118
- description="Get product by ID",
119
- mime_type="application/json",
120
- fn=product_fn,
121
- parameters={"id": {"type": "string", "description": "Product ID"}},
122
- )
123
- source_manager._templates["api://products/{id}"] = product_template
124
-
125
- # Create target manager
126
- target_manager = ResourceManager()
127
-
128
- # Import templates from source to target
129
- prefix = "shop+"
130
- target_manager.import_templates(source_manager, prefix)
131
-
132
- # Verify templates were imported with prefixes
133
- assert "shop+api://users/{id}" in target_manager._templates
134
- assert "shop+api://products/{id}" in target_manager._templates
135
-
136
- # Verify the original templates still exist in source manager
137
- assert "api://users/{id}" in source_manager._templates
138
- assert "api://products/{id}" in source_manager._templates
139
-
140
- # Verify the imported templates have the correct properties
141
- assert target_manager._templates["shop+api://users/{id}"].name == "user_template"
142
- assert (
143
- target_manager._templates["shop+api://users/{id}"].description
144
- == "Get user by ID"
145
- )
146
- assert (
147
- target_manager._templates["shop+api://users/{id}"].mime_type
148
- == "application/json"
149
- )
150
- assert target_manager._templates["shop+api://users/{id}"].parameters == {
151
- "id": {"type": "string", "description": "User ID"}
152
- }
153
-
154
- assert (
155
- target_manager._templates["shop+api://products/{id}"].name == "product_template"
156
- )
157
- assert (
158
- target_manager._templates["shop+api://products/{id}"].description
159
- == "Get product by ID"
160
- )
161
- assert (
162
- target_manager._templates["shop+api://products/{id}"].mime_type
163
- == "application/json"
164
- )
165
- assert target_manager._templates["shop+api://products/{id}"].parameters == {
166
- "id": {"type": "string", "description": "Product ID"}
167
- }
168
-
169
- # Verify the template functions were properly copied (only if the fn attribute exists)
170
- user_template = target_manager._templates["shop+api://users/{id}"]
171
- product_template = target_manager._templates["shop+api://products/{id}"]
172
-
173
- if hasattr(user_template, "fn") and hasattr(product_template, "fn"):
174
- assert user_template.fn.__name__ == user_fn.__name__
175
- assert product_template.fn.__name__ == product_fn.__name__
176
-
177
-
178
- def test_import_multiple_resource_types():
179
- """Test importing both resources and templates with the same prefix."""
180
- # Setup source manager with both resources and templates
181
- source_manager = ResourceManager()
182
-
183
- # Create mock functions
184
- async def resource_fn():
185
- return "Resource data"
186
-
187
- async def template_fn(**params):
188
- return f"Template data for id {params.get('id')}"
189
-
190
- # Add a resource to source manager
191
- resource = FunctionResource(
192
- uri=AnyUrl("data://resource"),
193
- name="test_resource",
194
- description="Test resource",
195
- mime_type="application/json",
196
- fn=resource_fn,
197
- )
198
- source_manager._resources["data://resource"] = resource
199
-
200
- # Add a template to source manager
201
- template = ResourceTemplate(
202
- uri_template="data://template/{id}",
203
- name="test_template",
204
- description="Test template",
205
- mime_type="application/json",
206
- fn=template_fn,
207
- parameters={"id": {"type": "string", "description": "ID parameter"}},
208
- )
209
- source_manager._templates["data://template/{id}"] = template
210
-
211
- # Create target manager
212
- target_manager = ResourceManager()
213
-
214
- # Import both resources and templates
215
- prefix = "test+"
216
- target_manager.import_resources(source_manager, prefix)
217
- target_manager.import_templates(source_manager, prefix)
218
-
219
- # Verify both resource types were imported with prefixes
220
- assert "test+data://resource" in target_manager._resources
221
- assert "test+data://template/{id}" in target_manager._templates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 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_tool_duplicate_behavior():
50
- """Test the behavior when importing tools with duplicate names."""
51
- # Setup source and target managers
52
- source_manager = ToolManager()
53
- target_manager = ToolManager()
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
- # The original tool in the target manager is replaced by the imported one
71
- assert target_manager._tools["source/common_tool"].fn.__name__ == source_fn.__name__
 
 
72
 
 
 
 
 
 
 
73
 
74
- def test_import_tools_with_multiple_prefixes():
75
- """Test importing tools from multiple managers with different prefixes."""
76
- # Setup source managers
77
- weather_manager = ToolManager()
78
- news_manager = ToolManager()
79
 
80
- # Add tools to source managers
81
- def forecast_fn():
82
- return "Weather forecast"
83
 
84
- def headlines_fn():
85
- return "News headlines"
 
 
 
86
 
87
- weather_manager.add_tool(forecast_fn, name="forecast")
88
- news_manager.add_tool(headlines_fn, name="headlines")
89
 
90
- # Create target manager and import from both sources
91
- main_manager = ToolManager()
92
- main_manager.import_tools(weather_manager, "weather/")
93
- main_manager.import_tools(news_manager, "news/")
94
 
95
- # Verify tools were imported with correct prefixes
96
- assert "weather/forecast" in main_manager._tools
97
- assert "news/headlines" in main_manager._tools
 
 
 
98
 
99
- # Verify the tools are accessible and functioning
100
- assert main_manager._tools["weather/forecast"].fn.__name__ == forecast_fn.__name__
101
- assert main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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