Spaces:
Running
Running
Merge pull request #143 from jlowin/methods
Browse filesExpand support for various method interactions
- docs/docs.json +2 -1
- docs/patterns/decorating-methods.mdx +198 -0
- src/fastmcp/exceptions.py +4 -0
- src/fastmcp/prompts/__init__.py +2 -2
- src/fastmcp/prompts/prompt.py +6 -16
- src/fastmcp/prompts/prompt_manager.py +2 -1
- src/fastmcp/resources/__init__.py +1 -1
- src/fastmcp/resources/resource_manager.py +89 -5
- src/fastmcp/server/context.py +1 -1
- src/fastmcp/server/proxy.py +4 -4
- src/fastmcp/server/server.py +146 -69
- src/fastmcp/tools/tool.py +4 -1
- src/fastmcp/utilities/decorators.py +101 -0
- src/fastmcp/utilities/func_metadata.py +4 -1
- tests/prompts/test_base.py +3 -3
- tests/prompts/test_prompt_manager.py +2 -1
- tests/resources/test_resource_manager.py +2 -1
- tests/server/test_file_server.py +4 -4
- tests/server/test_openapi.py +8 -8
- tests/server/test_proxy.py +22 -14
- tests/server/test_server.py +535 -18
- tests/utilities/test_decorated_function.py +222 -0
docs/docs.json
CHANGED
|
@@ -55,10 +55,11 @@
|
|
| 55 |
]
|
| 56 |
},
|
| 57 |
{
|
| 58 |
-
"group": "
|
| 59 |
"pages": [
|
| 60 |
"patterns/proxying",
|
| 61 |
"patterns/composition",
|
|
|
|
| 62 |
"patterns/openapi",
|
| 63 |
"patterns/fastapi"
|
| 64 |
]
|
|
|
|
| 55 |
]
|
| 56 |
},
|
| 57 |
{
|
| 58 |
+
"group": "Patterns",
|
| 59 |
"pages": [
|
| 60 |
"patterns/proxying",
|
| 61 |
"patterns/composition",
|
| 62 |
+
"patterns/decorating-methods",
|
| 63 |
"patterns/openapi",
|
| 64 |
"patterns/fastapi"
|
| 65 |
]
|
docs/patterns/decorating-methods.mdx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Decorating Methods
|
| 3 |
+
sidebarTitle: Decorating Methods
|
| 4 |
+
description: Properly use instance methods, class methods, and static methods with FastMCP decorators.
|
| 5 |
+
icon: at
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
FastMCP's decorator system is designed to work with functions, but you may see unexpected behavior if you try to decorate an instance or class method. This guide explains the correct approach for using methods with all FastMCP decorators (`@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`).
|
| 9 |
+
|
| 10 |
+
## Why Are Methods Hard?
|
| 11 |
+
|
| 12 |
+
When you apply a FastMCP decorator like `@mcp.tool()`, `@mcp.resource()`, or `@mcp.prompt()` to a method, the decorator captures the function at decoration time. For instance methods and class methods, this poses a challenge because:
|
| 13 |
+
|
| 14 |
+
1. For instance methods: The decorator gets the unbound method before any instance exists
|
| 15 |
+
2. For class methods: The decorator gets the function before it's bound to the class
|
| 16 |
+
|
| 17 |
+
This means directly decorating these methods doesn't work as expected. In practice, the LLM would see parameters like `self` or `cls` that it cannot provide values for.
|
| 18 |
+
|
| 19 |
+
## Recommended Patterns
|
| 20 |
+
|
| 21 |
+
### Instance Methods
|
| 22 |
+
|
| 23 |
+
**Don't do this** (it doesn't work properly):
|
| 24 |
+
|
| 25 |
+
```python
|
| 26 |
+
from fastmcp import FastMCP
|
| 27 |
+
|
| 28 |
+
mcp = FastMCP()
|
| 29 |
+
|
| 30 |
+
class MyClass:
|
| 31 |
+
@mcp.tool() # This won't work correctly
|
| 32 |
+
def add(self, x, y):
|
| 33 |
+
return x + y
|
| 34 |
+
|
| 35 |
+
@mcp.resource("resource://{param}") # This won't work correctly
|
| 36 |
+
def get_resource(self, param: str):
|
| 37 |
+
return f"Resource data for {param}"
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
When the decorator is applied this way, it captures the unbound method. When the LLM later tries to use this component, it will see `self` as a required parameter, but it won't know what to provide for it, causing errors or unexpected behavior.
|
| 41 |
+
|
| 42 |
+
**Do this instead**:
|
| 43 |
+
|
| 44 |
+
```python
|
| 45 |
+
from fastmcp import FastMCP
|
| 46 |
+
|
| 47 |
+
mcp = FastMCP()
|
| 48 |
+
|
| 49 |
+
class MyClass:
|
| 50 |
+
def add(self, x, y):
|
| 51 |
+
return x + y
|
| 52 |
+
|
| 53 |
+
def get_resource(self, param: str):
|
| 54 |
+
return f"Resource data for {param}"
|
| 55 |
+
|
| 56 |
+
# Create an instance first, then add the bound methods
|
| 57 |
+
obj = MyClass()
|
| 58 |
+
mcp.add_tool(obj.add)
|
| 59 |
+
mcp.add_resource(obj.get_resource, uri="resource://{param}") # For resources
|
| 60 |
+
|
| 61 |
+
# Now you can call it without 'self' showing up as a parameter
|
| 62 |
+
await mcp.call_tool('add', {'x': 1, 'y': 2}) # Returns 3
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
This approach works because:
|
| 66 |
+
1. You first create an instance of the class (`obj`)
|
| 67 |
+
2. When you access the method through the instance (`obj.add`), Python creates a bound method where `self` is already set to that instance
|
| 68 |
+
3. When you register this bound method, the system sees a callable that only expects the appropriate parameters, not `self`
|
| 69 |
+
|
| 70 |
+
### Class Methods
|
| 71 |
+
|
| 72 |
+
Similar to instance methods, decorating class methods directly doesn't work properly:
|
| 73 |
+
|
| 74 |
+
**Don't do this**:
|
| 75 |
+
|
| 76 |
+
```python
|
| 77 |
+
from fastmcp import FastMCP
|
| 78 |
+
|
| 79 |
+
mcp = FastMCP()
|
| 80 |
+
|
| 81 |
+
class MyClass:
|
| 82 |
+
@classmethod
|
| 83 |
+
@mcp.tool() # This won't work correctly
|
| 84 |
+
def from_string(cls, s):
|
| 85 |
+
return cls(s)
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
The problem here is that the FastMCP decorator is applied before the `@classmethod` decorator (Python applies decorators bottom-to-top). So it captures the function before it's transformed into a class method, leading to incorrect behavior.
|
| 89 |
+
|
| 90 |
+
**Do this instead**:
|
| 91 |
+
|
| 92 |
+
```python
|
| 93 |
+
from fastmcp import FastMCP
|
| 94 |
+
|
| 95 |
+
mcp = FastMCP()
|
| 96 |
+
|
| 97 |
+
class MyClass:
|
| 98 |
+
@classmethod
|
| 99 |
+
def from_string(cls, s):
|
| 100 |
+
return cls(s)
|
| 101 |
+
|
| 102 |
+
# Add the class method after the class is defined
|
| 103 |
+
mcp.add_tool(MyClass.from_string)
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
This works because:
|
| 107 |
+
1. The `@classmethod` decorator is applied properly during class definition
|
| 108 |
+
2. When you access `MyClass.from_string`, Python provides a special method object that automatically binds the class to the `cls` parameter
|
| 109 |
+
3. When registered, only the appropriate parameters are exposed to the LLM, hiding the implementation detail of the `cls` parameter
|
| 110 |
+
|
| 111 |
+
### Static Methods
|
| 112 |
+
|
| 113 |
+
Unlike instance and class methods, static methods work fine with FastMCP decorators:
|
| 114 |
+
|
| 115 |
+
```python
|
| 116 |
+
from fastmcp import FastMCP
|
| 117 |
+
|
| 118 |
+
mcp = FastMCP()
|
| 119 |
+
|
| 120 |
+
class MyClass:
|
| 121 |
+
@staticmethod
|
| 122 |
+
@mcp.tool() # This works!
|
| 123 |
+
def utility(x, y):
|
| 124 |
+
return x + y
|
| 125 |
+
|
| 126 |
+
@staticmethod
|
| 127 |
+
@mcp.resource("resource://data") # This works too!
|
| 128 |
+
def get_data():
|
| 129 |
+
return "Static resource data"
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
This approach works because:
|
| 133 |
+
1. The `@staticmethod` decorator is applied first (executed last), transforming the method into a regular function
|
| 134 |
+
2. When the FastMCP decorator is applied, it's capturing what is effectively just a regular function
|
| 135 |
+
3. A static method doesn't have any binding requirements - it doesn't receive a `self` or `cls` parameter
|
| 136 |
+
|
| 137 |
+
Alternatively, you can use the same pattern as the other methods:
|
| 138 |
+
|
| 139 |
+
```python
|
| 140 |
+
from fastmcp import FastMCP
|
| 141 |
+
|
| 142 |
+
mcp = FastMCP()
|
| 143 |
+
|
| 144 |
+
class MyClass:
|
| 145 |
+
@staticmethod
|
| 146 |
+
def utility(x, y):
|
| 147 |
+
return x + y
|
| 148 |
+
|
| 149 |
+
# This also works
|
| 150 |
+
mcp.add_tool(MyClass.utility)
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
This works for the same reason - a static method is essentially just a function in a class namespace.
|
| 154 |
+
|
| 155 |
+
## Additional Patterns
|
| 156 |
+
|
| 157 |
+
### Creating Components at Class Initialization
|
| 158 |
+
|
| 159 |
+
You can automatically register instance methods when creating an object:
|
| 160 |
+
|
| 161 |
+
```python
|
| 162 |
+
from fastmcp import FastMCP
|
| 163 |
+
|
| 164 |
+
mcp = FastMCP()
|
| 165 |
+
|
| 166 |
+
class ComponentProvider:
|
| 167 |
+
def __init__(self, mcp_instance):
|
| 168 |
+
# Register methods
|
| 169 |
+
mcp_instance.add_tool(self.tool_method)
|
| 170 |
+
mcp_instance.add_resource(self.resource_method, uri="resource://data")
|
| 171 |
+
|
| 172 |
+
def tool_method(self, x):
|
| 173 |
+
return x * 2
|
| 174 |
+
|
| 175 |
+
def resource_method(self):
|
| 176 |
+
return "Resource data"
|
| 177 |
+
|
| 178 |
+
# The methods are automatically registered when creating the instance
|
| 179 |
+
provider = ComponentProvider(mcp)
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
This pattern is useful when:
|
| 183 |
+
- You want to encapsulate registration logic within the class itself
|
| 184 |
+
- You have multiple related components that should be registered together
|
| 185 |
+
- You want to ensure that methods are always properly registered when creating an instance
|
| 186 |
+
|
| 187 |
+
The class automatically registers its methods during initialization, ensuring they're properly bound to the instance before registration.
|
| 188 |
+
|
| 189 |
+
## Summary
|
| 190 |
+
|
| 191 |
+
While FastMCP's decorator pattern works seamlessly with regular functions and static methods, for instance methods and class methods, you should add them after creating the instance or class. This ensures that the methods are properly bound before being registered.
|
| 192 |
+
|
| 193 |
+
These patterns apply to all FastMCP decorators and registration methods:
|
| 194 |
+
- `@mcp.tool()` and `mcp.add_tool()`
|
| 195 |
+
- `@mcp.resource()` and `mcp.add_resource()`
|
| 196 |
+
- `@mcp.prompt()` and `mcp.add_prompt()`
|
| 197 |
+
|
| 198 |
+
Understanding these patterns allows you to effectively organize your components into classes while maintaining proper method binding, giving you the benefits of object-oriented design without sacrificing the simplicity of FastMCP's decorator system.
|
src/fastmcp/exceptions.py
CHANGED
|
@@ -17,5 +17,9 @@ class ToolError(FastMCPError):
|
|
| 17 |
"""Error in tool operations."""
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
class InvalidSignature(Exception):
|
| 21 |
"""Invalid signature for use with FastMCP."""
|
|
|
|
| 17 |
"""Error in tool operations."""
|
| 18 |
|
| 19 |
|
| 20 |
+
class PromptError(FastMCPError):
|
| 21 |
+
"""Error in prompt operations."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
class InvalidSignature(Exception):
|
| 25 |
"""Invalid signature for use with FastMCP."""
|
src/fastmcp/prompts/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from .prompt import Prompt
|
| 2 |
from .prompt_manager import PromptManager
|
| 3 |
|
| 4 |
-
__all__ = ["Prompt", "PromptManager"]
|
|
|
|
| 1 |
+
from .prompt import Prompt, Message, UserMessage, AssistantMessage
|
| 2 |
from .prompt_manager import PromptManager
|
| 3 |
|
| 4 |
+
__all__ = ["Prompt", "PromptManager", "Message", "UserMessage", "AssistantMessage"]
|
src/fastmcp/prompts/prompt.py
CHANGED
|
@@ -27,27 +27,17 @@ class Message(BaseModel):
|
|
| 27 |
super().__init__(content=content, **kwargs)
|
| 28 |
|
| 29 |
|
| 30 |
-
|
| 31 |
"""A message from the user."""
|
|
|
|
| 32 |
|
| 33 |
-
role: Literal["user", "assistant"] = "user"
|
| 34 |
|
| 35 |
-
|
| 36 |
-
super().__init__(content=content, **kwargs)
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class AssistantMessage(Message):
|
| 40 |
"""A message from the assistant."""
|
|
|
|
| 41 |
|
| 42 |
-
role: Literal["user", "assistant"] = "assistant"
|
| 43 |
-
|
| 44 |
-
def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any):
|
| 45 |
-
super().__init__(content=content, **kwargs)
|
| 46 |
|
| 47 |
-
|
| 48 |
-
message_validator = TypeAdapter[UserMessage | AssistantMessage](
|
| 49 |
-
UserMessage | AssistantMessage
|
| 50 |
-
)
|
| 51 |
|
| 52 |
SyncPromptResult = (
|
| 53 |
str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
|
|
@@ -160,7 +150,7 @@ class Prompt(BaseModel):
|
|
| 160 |
messages.append(message_validator.validate_python(msg))
|
| 161 |
elif isinstance(msg, str):
|
| 162 |
content = TextContent(type="text", text=msg)
|
| 163 |
-
messages.append(
|
| 164 |
else:
|
| 165 |
content = json.dumps(pydantic_core.to_jsonable_python(msg))
|
| 166 |
messages.append(Message(role="user", content=content))
|
|
|
|
| 27 |
super().__init__(content=content, **kwargs)
|
| 28 |
|
| 29 |
|
| 30 |
+
def UserMessage(content: str | CONTENT_TYPES, **kwargs: Any) -> Message:
|
| 31 |
"""A message from the user."""
|
| 32 |
+
return Message(content=content, role="user", **kwargs)
|
| 33 |
|
|
|
|
| 34 |
|
| 35 |
+
def AssistantMessage(content: str | CONTENT_TYPES, **kwargs: Any) -> Message:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
"""A message from the assistant."""
|
| 37 |
+
return Message(content=content, role="assistant", **kwargs)
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
message_validator = TypeAdapter[Message](Message)
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
SyncPromptResult = (
|
| 43 |
str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
|
|
|
|
| 150 |
messages.append(message_validator.validate_python(msg))
|
| 151 |
elif isinstance(msg, str):
|
| 152 |
content = TextContent(type="text", text=msg)
|
| 153 |
+
messages.append(Message(role="user", content=content))
|
| 154 |
else:
|
| 155 |
content = json.dumps(pydantic_core.to_jsonable_python(msg))
|
| 156 |
messages.append(Message(role="user", content=content))
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from collections.abc import Awaitable, Callable
|
| 4 |
from typing import Any
|
| 5 |
|
|
|
|
| 6 |
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
| 7 |
from fastmcp.settings import DuplicateBehavior
|
| 8 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -61,7 +62,7 @@ class PromptManager:
|
|
| 61 |
"""Render a prompt by name with arguments."""
|
| 62 |
prompt = self.get_prompt(name)
|
| 63 |
if not prompt:
|
| 64 |
-
raise
|
| 65 |
|
| 66 |
return await prompt.render(arguments)
|
| 67 |
|
|
|
|
| 3 |
from collections.abc import Awaitable, Callable
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
+
from fastmcp.exceptions import PromptError
|
| 7 |
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
| 8 |
from fastmcp.settings import DuplicateBehavior
|
| 9 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 62 |
"""Render a prompt by name with arguments."""
|
| 63 |
prompt = self.get_prompt(name)
|
| 64 |
if not prompt:
|
| 65 |
+
raise PromptError(f"Unknown prompt: {name}")
|
| 66 |
|
| 67 |
return await prompt.render(arguments)
|
| 68 |
|
src/fastmcp/resources/__init__.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
from .resource import Resource
|
| 2 |
-
from .resource_manager import ResourceManager
|
| 3 |
from .template import ResourceTemplate
|
| 4 |
from .types import (
|
| 5 |
BinaryResource,
|
|
@@ -9,6 +8,7 @@ from .types import (
|
|
| 9 |
HttpResource,
|
| 10 |
TextResource,
|
| 11 |
)
|
|
|
|
| 12 |
|
| 13 |
__all__ = [
|
| 14 |
"Resource",
|
|
|
|
| 1 |
from .resource import Resource
|
|
|
|
| 2 |
from .template import ResourceTemplate
|
| 3 |
from .types import (
|
| 4 |
BinaryResource,
|
|
|
|
| 8 |
HttpResource,
|
| 9 |
TextResource,
|
| 10 |
)
|
| 11 |
+
from .resource_manager import ResourceManager
|
| 12 |
|
| 13 |
__all__ = [
|
| 14 |
"Resource",
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 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.
|
|
|
|
| 9 |
from fastmcp.resources.template import ResourceTemplate
|
| 10 |
from fastmcp.settings import DuplicateBehavior
|
| 11 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -21,16 +24,86 @@ class ResourceManager:
|
|
| 21 |
self._templates: dict[str, ResourceTemplate] = {}
|
| 22 |
self.duplicate_behavior = duplicate_behavior
|
| 23 |
|
| 24 |
-
def
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
Args:
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
Returns:
|
| 31 |
The added resource. If a resource with the same URI already exists,
|
| 32 |
returns the existing resource.
|
| 33 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
logger.debug(
|
| 35 |
"Adding resource",
|
| 36 |
extra={
|
|
@@ -63,6 +136,17 @@ class ResourceManager:
|
|
| 63 |
tags: set[str] | None = None,
|
| 64 |
) -> ResourceTemplate:
|
| 65 |
"""Create a template from a function."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
template = ResourceTemplate.from_function(
|
| 67 |
fn,
|
| 68 |
uri_template=uri_template,
|
|
@@ -122,7 +206,7 @@ class ResourceManager:
|
|
| 122 |
except Exception as e:
|
| 123 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 124 |
|
| 125 |
-
raise
|
| 126 |
|
| 127 |
def list_resources(self) -> list[Resource]:
|
| 128 |
"""List all registered resources."""
|
|
|
|
| 1 |
"""Resource manager functionality."""
|
| 2 |
|
| 3 |
+
import inspect
|
| 4 |
+
import re
|
| 5 |
from collections.abc import Callable
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
from pydantic import AnyUrl
|
| 9 |
|
| 10 |
+
from fastmcp.exceptions import ResourceError
|
| 11 |
+
from fastmcp.resources import FunctionResource, Resource
|
| 12 |
from fastmcp.resources.template import ResourceTemplate
|
| 13 |
from fastmcp.settings import DuplicateBehavior
|
| 14 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 24 |
self._templates: dict[str, ResourceTemplate] = {}
|
| 25 |
self.duplicate_behavior = duplicate_behavior
|
| 26 |
|
| 27 |
+
def add_resource_or_template_from_fn(
|
| 28 |
+
self,
|
| 29 |
+
fn: Callable[..., Any],
|
| 30 |
+
uri: str,
|
| 31 |
+
name: str | None = None,
|
| 32 |
+
description: str | None = None,
|
| 33 |
+
mime_type: str | None = None,
|
| 34 |
+
tags: set[str] | None = None,
|
| 35 |
+
) -> Resource | ResourceTemplate:
|
| 36 |
+
"""Add a resource or template to the manager from a function.
|
| 37 |
|
| 38 |
Args:
|
| 39 |
+
fn: The function to register as a resource or template
|
| 40 |
+
uri: The URI for the resource or template
|
| 41 |
+
name: Optional name for the resource or template
|
| 42 |
+
description: Optional description of the resource or template
|
| 43 |
+
mime_type: Optional MIME type for the resource or template
|
| 44 |
+
tags: Optional set of tags for categorizing the resource or template
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
The added resource or template. If a resource or template with the same URI already exists,
|
| 48 |
+
returns the existing resource or template.
|
| 49 |
+
"""
|
| 50 |
+
# Check if this should be a template
|
| 51 |
+
has_uri_params = "{" in uri and "}" in uri
|
| 52 |
+
has_func_params = bool(inspect.signature(fn).parameters)
|
| 53 |
+
|
| 54 |
+
if has_uri_params and has_func_params:
|
| 55 |
+
return self.add_template_from_fn(
|
| 56 |
+
fn, uri, name, description, mime_type, tags
|
| 57 |
+
)
|
| 58 |
+
elif not has_uri_params and not has_func_params:
|
| 59 |
+
return self.add_resource_from_fn(
|
| 60 |
+
fn, uri, name, description, mime_type, tags
|
| 61 |
+
)
|
| 62 |
+
else:
|
| 63 |
+
raise ValueError(
|
| 64 |
+
"Invalid resource or template definition due to a "
|
| 65 |
+
"mismatch between URI parameters and function parameters."
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def add_resource_from_fn(
|
| 69 |
+
self,
|
| 70 |
+
fn: Callable[..., Any],
|
| 71 |
+
uri: str,
|
| 72 |
+
name: str | None = None,
|
| 73 |
+
description: str | None = None,
|
| 74 |
+
mime_type: str | None = None,
|
| 75 |
+
tags: set[str] | None = None,
|
| 76 |
+
) -> Resource:
|
| 77 |
+
"""Add a resource to the manager from a function.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
fn: The function to register as a resource
|
| 81 |
+
uri: The URI for the resource
|
| 82 |
+
name: Optional name for the resource
|
| 83 |
+
description: Optional description of the resource
|
| 84 |
+
mime_type: Optional MIME type for the resource
|
| 85 |
+
tags: Optional set of tags for categorizing the resource
|
| 86 |
|
| 87 |
Returns:
|
| 88 |
The added resource. If a resource with the same URI already exists,
|
| 89 |
returns the existing resource.
|
| 90 |
"""
|
| 91 |
+
resource = FunctionResource(
|
| 92 |
+
uri=AnyUrl(uri),
|
| 93 |
+
name=name,
|
| 94 |
+
description=description,
|
| 95 |
+
mime_type=mime_type or "text/plain",
|
| 96 |
+
fn=fn,
|
| 97 |
+
tags=tags or set(),
|
| 98 |
+
)
|
| 99 |
+
return self.add_resource(resource)
|
| 100 |
+
|
| 101 |
+
def add_resource(self, resource: Resource) -> Resource:
|
| 102 |
+
"""Add a resource to the manager.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
resource: A Resource instance to add
|
| 106 |
+
"""
|
| 107 |
logger.debug(
|
| 108 |
"Adding resource",
|
| 109 |
extra={
|
|
|
|
| 136 |
tags: set[str] | None = None,
|
| 137 |
) -> ResourceTemplate:
|
| 138 |
"""Create a template from a function."""
|
| 139 |
+
|
| 140 |
+
# Validate that URI params match function params
|
| 141 |
+
uri_params = set(re.findall(r"{(\w+)}", uri_template))
|
| 142 |
+
func_params = set(inspect.signature(fn).parameters.keys())
|
| 143 |
+
|
| 144 |
+
if uri_params != func_params:
|
| 145 |
+
raise ValueError(
|
| 146 |
+
f"Mismatch between URI parameters {uri_params} "
|
| 147 |
+
f"and function parameters {func_params}"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
template = ResourceTemplate.from_function(
|
| 151 |
fn,
|
| 152 |
uri_template=uri_template,
|
|
|
|
| 206 |
except Exception as e:
|
| 207 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 208 |
|
| 209 |
+
raise ResourceError(f"Unknown resource: {uri}")
|
| 210 |
|
| 211 |
def list_resources(self) -> list[Resource]:
|
| 212 |
"""List all registered resources."""
|
src/fastmcp/server/context.py
CHANGED
|
@@ -118,7 +118,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
| 118 |
assert self._fastmcp is not None, (
|
| 119 |
"Context is not available outside of a request"
|
| 120 |
)
|
| 121 |
-
return await self._fastmcp.
|
| 122 |
|
| 123 |
async def log(
|
| 124 |
self,
|
|
|
|
| 118 |
assert self._fastmcp is not None, (
|
| 119 |
"Context is not available outside of a request"
|
| 120 |
)
|
| 121 |
+
return await self._fastmcp._mcp_read_resource(uri)
|
| 122 |
|
| 123 |
async def log(
|
| 124 |
self,
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
from typing import Any, cast
|
| 2 |
|
| 3 |
import mcp.types
|
| 4 |
-
from mcp.types import BlobResourceContents,
|
| 5 |
|
| 6 |
import fastmcp
|
| 7 |
from fastmcp.client import Client
|
| 8 |
-
from fastmcp.prompts import Prompt
|
| 9 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 10 |
from fastmcp.server.context import Context
|
| 11 |
from fastmcp.server.server import FastMCP
|
|
@@ -142,10 +142,10 @@ class ProxyPrompt(Prompt):
|
|
| 142 |
fn=_proxy_passthrough,
|
| 143 |
)
|
| 144 |
|
| 145 |
-
async def render(self, arguments: dict[str, Any]) -> list[
|
| 146 |
async with self._client:
|
| 147 |
result = await self._client.get_prompt(self.name, arguments)
|
| 148 |
-
return result.messages
|
| 149 |
|
| 150 |
|
| 151 |
class FastMCPProxy(FastMCP):
|
|
|
|
| 1 |
from typing import Any, cast
|
| 2 |
|
| 3 |
import mcp.types
|
| 4 |
+
from mcp.types import BlobResourceContents, TextResourceContents
|
| 5 |
|
| 6 |
import fastmcp
|
| 7 |
from fastmcp.client import Client
|
| 8 |
+
from fastmcp.prompts import Message, Prompt
|
| 9 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 10 |
from fastmcp.server.context import Context
|
| 11 |
from fastmcp.server.server import FastMCP
|
|
|
|
| 142 |
fn=_proxy_passthrough,
|
| 143 |
)
|
| 144 |
|
| 145 |
+
async def render(self, arguments: dict[str, Any]) -> list[Message]:
|
| 146 |
async with self._client:
|
| 147 |
result = await self._client.get_prompt(self.name, arguments)
|
| 148 |
+
return [Message(role=m.role, content=m.content) for m in result.messages]
|
| 149 |
|
| 150 |
|
| 151 |
class FastMCPProxy(FastMCP):
|
src/fastmcp/server/server.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
-
import inspect
|
| 4 |
import json
|
| 5 |
-
import
|
| 6 |
-
from collections.abc import AsyncIterator, Callable
|
| 7 |
from contextlib import (
|
| 8 |
AbstractAsyncContextManager,
|
| 9 |
AsyncExitStack,
|
|
@@ -43,8 +41,12 @@ import fastmcp
|
|
| 43 |
import fastmcp.settings
|
| 44 |
from fastmcp.exceptions import ResourceError
|
| 45 |
from fastmcp.prompts import Prompt, PromptManager
|
| 46 |
-
from fastmcp.
|
|
|
|
|
|
|
| 47 |
from fastmcp.tools import ToolManager
|
|
|
|
|
|
|
| 48 |
from fastmcp.utilities.logging import configure_logging, get_logger
|
| 49 |
from fastmcp.utilities.types import Image
|
| 50 |
|
|
@@ -171,18 +173,27 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 171 |
|
| 172 |
def _setup_handlers(self) -> None:
|
| 173 |
"""Set up core MCP protocol handlers."""
|
| 174 |
-
self._mcp_server.list_tools()(self.
|
| 175 |
self._mcp_server.call_tool()(self.call_tool)
|
| 176 |
-
self._mcp_server.list_resources()(self.
|
| 177 |
-
self._mcp_server.read_resource()(self.
|
| 178 |
-
self._mcp_server.list_prompts()(self.
|
| 179 |
-
self._mcp_server.get_prompt()(self.
|
| 180 |
-
self._mcp_server.list_resource_templates()(self.
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
tools = self._tool_manager.list_tools()
|
| 186 |
return [
|
| 187 |
MCPTool(
|
| 188 |
name=info.name,
|
|
@@ -215,10 +226,18 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 215 |
converted_result = _convert_to_content(result)
|
| 216 |
return converted_result
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
-
resources = self.
|
| 222 |
return [
|
| 223 |
MCPResource(
|
| 224 |
uri=resource.uri,
|
|
@@ -229,8 +248,18 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 229 |
for resource in resources
|
| 230 |
]
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
return [
|
| 235 |
MCPResourceTemplate(
|
| 236 |
uriTemplate=template.uri_template,
|
|
@@ -240,15 +269,27 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 240 |
for template in templates
|
| 241 |
]
|
| 242 |
|
| 243 |
-
async def read_resource(self, uri: AnyUrl | str) ->
|
| 244 |
"""Read a resource by URI."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
resource = await self._resource_manager.get_resource(uri)
|
| 247 |
if not resource:
|
| 248 |
raise ResourceError(f"Unknown resource: {uri}")
|
| 249 |
|
| 250 |
try:
|
| 251 |
-
content = await
|
| 252 |
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
|
| 253 |
except Exception as e:
|
| 254 |
logger.error(f"Error reading resource {uri}: {e}")
|
|
@@ -308,6 +349,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 308 |
await context.report_progress(50, 100)
|
| 309 |
return str(x)
|
| 310 |
"""
|
|
|
|
| 311 |
# Check if user passed function directly instead of calling decorator
|
| 312 |
if callable(name):
|
| 313 |
raise TypeError(
|
|
@@ -317,7 +359,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 317 |
|
| 318 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 319 |
self.add_tool(fn, name=name, description=description, tags=tags)
|
| 320 |
-
return fn
|
| 321 |
|
| 322 |
return decorator
|
| 323 |
|
|
@@ -327,8 +369,40 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 327 |
Args:
|
| 328 |
resource: A Resource instance to add
|
| 329 |
"""
|
|
|
|
| 330 |
self._resource_manager.add_resource(resource)
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
def resource(
|
| 333 |
self,
|
| 334 |
uri: str,
|
|
@@ -383,52 +457,36 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 383 |
)
|
| 384 |
|
| 385 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
if uri_params != func_params:
|
| 396 |
-
raise ValueError(
|
| 397 |
-
f"Mismatch between URI parameters {uri_params} "
|
| 398 |
-
f"and function parameters {func_params}"
|
| 399 |
-
)
|
| 400 |
-
|
| 401 |
-
# Register as template
|
| 402 |
-
self._resource_manager.add_template_from_fn(
|
| 403 |
-
fn=fn,
|
| 404 |
-
uri_template=uri,
|
| 405 |
-
name=name,
|
| 406 |
-
description=description,
|
| 407 |
-
mime_type=mime_type or "text/plain",
|
| 408 |
-
tags=tags,
|
| 409 |
-
)
|
| 410 |
-
else:
|
| 411 |
-
# Register as regular resource
|
| 412 |
-
resource = FunctionResource(
|
| 413 |
-
uri=AnyUrl(uri),
|
| 414 |
-
name=name,
|
| 415 |
-
description=description,
|
| 416 |
-
mime_type=mime_type or "text/plain",
|
| 417 |
-
fn=fn,
|
| 418 |
-
tags=tags or set(), # Default to empty set if None
|
| 419 |
-
)
|
| 420 |
-
self.add_resource(resource)
|
| 421 |
-
return fn
|
| 422 |
|
| 423 |
return decorator
|
| 424 |
|
| 425 |
-
def add_prompt(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
"""Add a prompt to the server.
|
| 427 |
|
| 428 |
Args:
|
| 429 |
prompt: A Prompt instance to add
|
| 430 |
"""
|
| 431 |
-
self._prompt_manager.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
def prompt(
|
| 434 |
self,
|
|
@@ -478,11 +536,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 478 |
)
|
| 479 |
|
| 480 |
def decorator(func: AnyFunction) -> AnyFunction:
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
)
|
| 484 |
-
self.add_prompt(prompt)
|
| 485 |
-
return func
|
| 486 |
|
| 487 |
return decorator
|
| 488 |
|
|
@@ -537,9 +592,20 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 537 |
],
|
| 538 |
)
|
| 539 |
|
| 540 |
-
|
| 541 |
-
"""
|
| 542 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
return [
|
| 544 |
MCPPrompt(
|
| 545 |
name=prompt.name,
|
|
@@ -558,10 +624,21 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 558 |
|
| 559 |
async def get_prompt(
|
| 560 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 561 |
-
) ->
|
| 562 |
"""Get a prompt by name with arguments."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
try:
|
| 564 |
-
messages = await self.
|
| 565 |
|
| 566 |
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
| 567 |
except Exception as e:
|
|
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
|
|
|
| 3 |
import json
|
| 4 |
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
|
|
| 5 |
from contextlib import (
|
| 6 |
AbstractAsyncContextManager,
|
| 7 |
AsyncExitStack,
|
|
|
|
| 41 |
import fastmcp.settings
|
| 42 |
from fastmcp.exceptions import ResourceError
|
| 43 |
from fastmcp.prompts import Prompt, PromptManager
|
| 44 |
+
from fastmcp.prompts.prompt import Message, PromptResult
|
| 45 |
+
from fastmcp.resources import Resource, ResourceManager
|
| 46 |
+
from fastmcp.resources.template import ResourceTemplate
|
| 47 |
from fastmcp.tools import ToolManager
|
| 48 |
+
from fastmcp.tools.tool import Tool
|
| 49 |
+
from fastmcp.utilities.decorators import DecoratedFunction
|
| 50 |
from fastmcp.utilities.logging import configure_logging, get_logger
|
| 51 |
from fastmcp.utilities.types import Image
|
| 52 |
|
|
|
|
| 173 |
|
| 174 |
def _setup_handlers(self) -> None:
|
| 175 |
"""Set up core MCP protocol handlers."""
|
| 176 |
+
self._mcp_server.list_tools()(self._mcp_list_tools)
|
| 177 |
self._mcp_server.call_tool()(self.call_tool)
|
| 178 |
+
self._mcp_server.list_resources()(self._mcp_list_resources)
|
| 179 |
+
self._mcp_server.read_resource()(self._mcp_read_resource)
|
| 180 |
+
self._mcp_server.list_prompts()(self._mcp_list_prompts)
|
| 181 |
+
self._mcp_server.get_prompt()(self._mcp_get_prompt)
|
| 182 |
+
self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
|
| 183 |
|
| 184 |
+
def list_tools(self) -> list[Tool]:
|
| 185 |
+
return self._tool_manager.list_tools()
|
| 186 |
+
|
| 187 |
+
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 188 |
+
"""
|
| 189 |
+
List all available tools, in the format expected by the low-level MCP
|
| 190 |
+
server.
|
| 191 |
+
|
| 192 |
+
See `list_tools` for a more ergonomic way to list tools.
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
tools = self.list_tools()
|
| 196 |
|
|
|
|
| 197 |
return [
|
| 198 |
MCPTool(
|
| 199 |
name=info.name,
|
|
|
|
| 226 |
converted_result = _convert_to_content(result)
|
| 227 |
return converted_result
|
| 228 |
|
| 229 |
+
def list_resources(self) -> list[Resource]:
|
| 230 |
+
return self._resource_manager.list_resources()
|
| 231 |
+
|
| 232 |
+
async def _mcp_list_resources(self) -> list[MCPResource]:
|
| 233 |
+
"""
|
| 234 |
+
List all available resources, in the format expected by the low-level MCP
|
| 235 |
+
server.
|
| 236 |
+
|
| 237 |
+
See `list_resources` for a more ergonomic way to list resources.
|
| 238 |
+
"""
|
| 239 |
|
| 240 |
+
resources = self.list_resources()
|
| 241 |
return [
|
| 242 |
MCPResource(
|
| 243 |
uri=resource.uri,
|
|
|
|
| 248 |
for resource in resources
|
| 249 |
]
|
| 250 |
|
| 251 |
+
def list_resource_templates(self) -> list[ResourceTemplate]:
|
| 252 |
+
return self._resource_manager.list_templates()
|
| 253 |
+
|
| 254 |
+
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
| 255 |
+
"""
|
| 256 |
+
List all available resource templates, in the format expected by the low-level
|
| 257 |
+
MCP server.
|
| 258 |
+
|
| 259 |
+
See `list_resource_templates` for a more ergonomic way to list resource
|
| 260 |
+
templates.
|
| 261 |
+
"""
|
| 262 |
+
templates = self.list_resource_templates()
|
| 263 |
return [
|
| 264 |
MCPResourceTemplate(
|
| 265 |
uriTemplate=template.uri_template,
|
|
|
|
| 269 |
for template in templates
|
| 270 |
]
|
| 271 |
|
| 272 |
+
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
| 273 |
"""Read a resource by URI."""
|
| 274 |
+
resource = await self._resource_manager.get_resource(uri)
|
| 275 |
+
if not resource:
|
| 276 |
+
raise ResourceError(f"Unknown resource: {uri}")
|
| 277 |
+
return await resource.read()
|
| 278 |
+
|
| 279 |
+
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 280 |
+
"""
|
| 281 |
+
Read a resource by URI, in the format expected by the low-level MCP
|
| 282 |
+
server.
|
| 283 |
+
|
| 284 |
+
See `read_resource` for a more ergonomic way to read resources.
|
| 285 |
+
"""
|
| 286 |
|
| 287 |
resource = await self._resource_manager.get_resource(uri)
|
| 288 |
if not resource:
|
| 289 |
raise ResourceError(f"Unknown resource: {uri}")
|
| 290 |
|
| 291 |
try:
|
| 292 |
+
content = await self.read_resource(uri)
|
| 293 |
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
|
| 294 |
except Exception as e:
|
| 295 |
logger.error(f"Error reading resource {uri}: {e}")
|
|
|
|
| 349 |
await context.report_progress(50, 100)
|
| 350 |
return str(x)
|
| 351 |
"""
|
| 352 |
+
|
| 353 |
# Check if user passed function directly instead of calling decorator
|
| 354 |
if callable(name):
|
| 355 |
raise TypeError(
|
|
|
|
| 359 |
|
| 360 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 361 |
self.add_tool(fn, name=name, description=description, tags=tags)
|
| 362 |
+
return DecoratedFunction(fn)
|
| 363 |
|
| 364 |
return decorator
|
| 365 |
|
|
|
|
| 369 |
Args:
|
| 370 |
resource: A Resource instance to add
|
| 371 |
"""
|
| 372 |
+
|
| 373 |
self._resource_manager.add_resource(resource)
|
| 374 |
|
| 375 |
+
def add_resource_from_fn(
|
| 376 |
+
self,
|
| 377 |
+
fn: AnyFunction,
|
| 378 |
+
uri: str,
|
| 379 |
+
name: str | None = None,
|
| 380 |
+
description: str | None = None,
|
| 381 |
+
mime_type: str | None = None,
|
| 382 |
+
tags: set[str] | None = None,
|
| 383 |
+
) -> None:
|
| 384 |
+
"""Add a resource or template to the server from a function.
|
| 385 |
+
|
| 386 |
+
If the URI contains parameters (e.g. "resource://{param}") or the function
|
| 387 |
+
has parameters, it will be registered as a template resource.
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
fn: The function to register as a resource
|
| 391 |
+
uri: The URI for the resource
|
| 392 |
+
name: Optional name for the resource
|
| 393 |
+
description: Optional description of the resource
|
| 394 |
+
mime_type: Optional MIME type for the resource
|
| 395 |
+
tags: Optional set of tags for categorizing the resource
|
| 396 |
+
"""
|
| 397 |
+
self._resource_manager.add_resource_or_template_from_fn(
|
| 398 |
+
fn=fn,
|
| 399 |
+
uri=uri,
|
| 400 |
+
name=name,
|
| 401 |
+
description=description,
|
| 402 |
+
mime_type=mime_type,
|
| 403 |
+
tags=tags,
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
def resource(
|
| 407 |
self,
|
| 408 |
uri: str,
|
|
|
|
| 457 |
)
|
| 458 |
|
| 459 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 460 |
+
self._resource_manager.add_resource_or_template_from_fn(
|
| 461 |
+
fn=fn,
|
| 462 |
+
uri=uri,
|
| 463 |
+
name=name,
|
| 464 |
+
description=description,
|
| 465 |
+
mime_type=mime_type,
|
| 466 |
+
tags=tags,
|
| 467 |
+
)
|
| 468 |
+
return DecoratedFunction(fn)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
return decorator
|
| 471 |
|
| 472 |
+
def add_prompt(
|
| 473 |
+
self,
|
| 474 |
+
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
| 475 |
+
name: str | None = None,
|
| 476 |
+
description: str | None = None,
|
| 477 |
+
tags: set[str] | None = None,
|
| 478 |
+
) -> None:
|
| 479 |
"""Add a prompt to the server.
|
| 480 |
|
| 481 |
Args:
|
| 482 |
prompt: A Prompt instance to add
|
| 483 |
"""
|
| 484 |
+
self._prompt_manager.add_prompt_from_fn(
|
| 485 |
+
fn=fn,
|
| 486 |
+
name=name,
|
| 487 |
+
description=description,
|
| 488 |
+
tags=tags,
|
| 489 |
+
)
|
| 490 |
|
| 491 |
def prompt(
|
| 492 |
self,
|
|
|
|
| 536 |
)
|
| 537 |
|
| 538 |
def decorator(func: AnyFunction) -> AnyFunction:
|
| 539 |
+
self.add_prompt(func, name=name, description=description, tags=tags)
|
| 540 |
+
return DecoratedFunction(func)
|
|
|
|
|
|
|
|
|
|
| 541 |
|
| 542 |
return decorator
|
| 543 |
|
|
|
|
| 592 |
],
|
| 593 |
)
|
| 594 |
|
| 595 |
+
def list_prompts(self) -> list[Prompt]:
|
| 596 |
+
"""
|
| 597 |
+
List all available prompts.
|
| 598 |
+
"""
|
| 599 |
+
return self._prompt_manager.list_prompts()
|
| 600 |
+
|
| 601 |
+
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
| 602 |
+
"""
|
| 603 |
+
List all available prompts, in the format expected by the low-level MCP
|
| 604 |
+
server.
|
| 605 |
+
|
| 606 |
+
See `list_prompts` for a more ergonomic way to list prompts.
|
| 607 |
+
"""
|
| 608 |
+
prompts = self.list_prompts()
|
| 609 |
return [
|
| 610 |
MCPPrompt(
|
| 611 |
name=prompt.name,
|
|
|
|
| 624 |
|
| 625 |
async def get_prompt(
|
| 626 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 627 |
+
) -> list[Message]:
|
| 628 |
"""Get a prompt by name with arguments."""
|
| 629 |
+
return await self._prompt_manager.render_prompt(name, arguments)
|
| 630 |
+
|
| 631 |
+
async def _mcp_get_prompt(
|
| 632 |
+
self, name: str, arguments: dict[str, Any] | None = None
|
| 633 |
+
) -> GetPromptResult:
|
| 634 |
+
"""
|
| 635 |
+
Get a prompt by name with arguments, in the format expected by the low-level
|
| 636 |
+
MCP server.
|
| 637 |
+
|
| 638 |
+
See `get_prompt` for a more ergonomic way to get prompts.
|
| 639 |
+
"""
|
| 640 |
try:
|
| 641 |
+
messages = await self.get_prompt(name, arguments)
|
| 642 |
|
| 643 |
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
| 644 |
except Exception as e:
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -58,7 +58,10 @@ class Tool(BaseModel):
|
|
| 58 |
is_async = inspect.iscoroutinefunction(fn)
|
| 59 |
|
| 60 |
if context_kwarg is None:
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
for param_name, param in sig.parameters.items():
|
| 63 |
if param.annotation is Context:
|
| 64 |
context_kwarg = param_name
|
|
|
|
| 58 |
is_async = inspect.iscoroutinefunction(fn)
|
| 59 |
|
| 60 |
if context_kwarg is None:
|
| 61 |
+
if isinstance(fn, classmethod):
|
| 62 |
+
sig = inspect.signature(fn.__func__)
|
| 63 |
+
else:
|
| 64 |
+
sig = inspect.signature(fn)
|
| 65 |
for param_name, param in sig.parameters.items():
|
| 66 |
if param.annotation is Context:
|
| 67 |
context_kwarg = param_name
|
src/fastmcp/utilities/decorators.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
from collections.abc import Callable
|
| 3 |
+
from typing import Generic, ParamSpec, TypeVar, cast, overload
|
| 4 |
+
|
| 5 |
+
from typing_extensions import Self
|
| 6 |
+
|
| 7 |
+
R = TypeVar("R")
|
| 8 |
+
P = ParamSpec("P")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DecoratedFunction(Generic[P, R]):
|
| 12 |
+
"""Descriptor for decorated functions.
|
| 13 |
+
|
| 14 |
+
You can return this object from a decorator to ensure that it works across
|
| 15 |
+
all types of functions: vanilla, instance methods, class methods, and static
|
| 16 |
+
methods; both synchronous and asynchronous.
|
| 17 |
+
|
| 18 |
+
This class is used to store the original function and metadata about how to
|
| 19 |
+
register it as a tool.
|
| 20 |
+
|
| 21 |
+
Example usage:
|
| 22 |
+
|
| 23 |
+
```python
|
| 24 |
+
def my_decorator(fn: Callable[P, R]) -> DecoratedFunction[P, R]:
|
| 25 |
+
return DecoratedFunction(fn)
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
On a function:
|
| 29 |
+
```python
|
| 30 |
+
@my_decorator
|
| 31 |
+
def my_function(a: int, b: int) -> int:
|
| 32 |
+
return a + b
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
On an instance method:
|
| 36 |
+
```python
|
| 37 |
+
class Test:
|
| 38 |
+
@my_decorator
|
| 39 |
+
def my_function(self, a: int, b: int) -> int:
|
| 40 |
+
return a + b
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
On a class method:
|
| 44 |
+
```python
|
| 45 |
+
class Test:
|
| 46 |
+
@classmethod
|
| 47 |
+
@my_decorator
|
| 48 |
+
def my_function(cls, a: int, b: int) -> int:
|
| 49 |
+
return a + b
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Note that for classmethods, the decorator must be applied first, then
|
| 53 |
+
`@classmethod` on top.
|
| 54 |
+
|
| 55 |
+
On a static method:
|
| 56 |
+
```python
|
| 57 |
+
class Test:
|
| 58 |
+
@staticmethod
|
| 59 |
+
@my_decorator
|
| 60 |
+
def my_function(a: int, b: int) -> int:
|
| 61 |
+
return a + b
|
| 62 |
+
```
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self, fn: Callable[P, R]):
|
| 66 |
+
self.fn = fn
|
| 67 |
+
|
| 68 |
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
| 69 |
+
"""Call the original function."""
|
| 70 |
+
try:
|
| 71 |
+
return self.fn(*args, **kwargs)
|
| 72 |
+
except TypeError as e:
|
| 73 |
+
if "'classmethod' object is not callable" in str(e):
|
| 74 |
+
raise TypeError(
|
| 75 |
+
"To apply this decorator to a classmethod, apply the decorator first, then @classmethod on top."
|
| 76 |
+
)
|
| 77 |
+
raise
|
| 78 |
+
|
| 79 |
+
@overload
|
| 80 |
+
def __get__(self, instance: None, owner: type | None = None) -> Self: ...
|
| 81 |
+
|
| 82 |
+
@overload
|
| 83 |
+
def __get__(
|
| 84 |
+
self, instance: object, owner: type | None = None
|
| 85 |
+
) -> Callable[P, R]: ...
|
| 86 |
+
|
| 87 |
+
def __get__(
|
| 88 |
+
self, instance: object | None, owner: type | None = None
|
| 89 |
+
) -> Self | Callable[P, R]:
|
| 90 |
+
"""Return the original function when accessed from an instance, or self when accessed from the class."""
|
| 91 |
+
if instance is None:
|
| 92 |
+
return self
|
| 93 |
+
# Return the original function bound to the instance
|
| 94 |
+
return cast(Callable[P, R], self.fn.__get__(instance, owner))
|
| 95 |
+
|
| 96 |
+
def __repr__(self) -> str:
|
| 97 |
+
"""Return a representation that matches Python's function representation."""
|
| 98 |
+
module = getattr(self.fn, "__module__", "unknown")
|
| 99 |
+
qualname = getattr(self.fn, "__qualname__", str(self.fn))
|
| 100 |
+
sig_str = str(inspect.signature(self.fn))
|
| 101 |
+
return f"<function {module}.{qualname}{sig_str}>"
|
src/fastmcp/utilities/func_metadata.py
CHANGED
|
@@ -125,7 +125,10 @@ def func_metadata(
|
|
| 125 |
Returns:
|
| 126 |
A pydantic model representing the function's signature.
|
| 127 |
"""
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
params = sig.parameters
|
| 130 |
dynamic_pydantic_model_params: dict[str, Any] = {}
|
| 131 |
globalns = getattr(func, "__globals__", {})
|
|
|
|
| 125 |
Returns:
|
| 126 |
A pydantic model representing the function's signature.
|
| 127 |
"""
|
| 128 |
+
if isinstance(func, classmethod):
|
| 129 |
+
sig = _get_typed_signature(func.__func__)
|
| 130 |
+
else:
|
| 131 |
+
sig = _get_typed_signature(func)
|
| 132 |
params = sig.parameters
|
| 133 |
dynamic_pydantic_model_params: dict[str, Any] = {}
|
| 134 |
globalns = getattr(func, "__globals__", {})
|
tests/prompts/test_base.py
CHANGED
|
@@ -57,7 +57,7 @@ class TestRenderPrompt:
|
|
| 57 |
|
| 58 |
@pytest.mark.anyio
|
| 59 |
async def test_fn_returns_message(self):
|
| 60 |
-
async def fn() ->
|
| 61 |
return UserMessage(content="Hello, world!")
|
| 62 |
|
| 63 |
prompt = Prompt.from_function(fn)
|
|
@@ -67,7 +67,7 @@ class TestRenderPrompt:
|
|
| 67 |
|
| 68 |
@pytest.mark.anyio
|
| 69 |
async def test_fn_returns_assistant_message(self):
|
| 70 |
-
async def fn() ->
|
| 71 |
return AssistantMessage(
|
| 72 |
content=TextContent(type="text", text="Hello, world!")
|
| 73 |
)
|
|
@@ -108,7 +108,7 @@ class TestRenderPrompt:
|
|
| 108 |
async def test_fn_returns_resource_content(self):
|
| 109 |
"""Test returning a message with resource content."""
|
| 110 |
|
| 111 |
-
async def fn() ->
|
| 112 |
return UserMessage(
|
| 113 |
content=EmbeddedResource(
|
| 114 |
type="resource",
|
|
|
|
| 57 |
|
| 58 |
@pytest.mark.anyio
|
| 59 |
async def test_fn_returns_message(self):
|
| 60 |
+
async def fn() -> Message:
|
| 61 |
return UserMessage(content="Hello, world!")
|
| 62 |
|
| 63 |
prompt = Prompt.from_function(fn)
|
|
|
|
| 67 |
|
| 68 |
@pytest.mark.anyio
|
| 69 |
async def test_fn_returns_assistant_message(self):
|
| 70 |
+
async def fn() -> Message:
|
| 71 |
return AssistantMessage(
|
| 72 |
content=TextContent(type="text", text="Hello, world!")
|
| 73 |
)
|
|
|
|
| 108 |
async def test_fn_returns_resource_content(self):
|
| 109 |
"""Test returning a message with resource content."""
|
| 110 |
|
| 111 |
+
async def fn() -> Message:
|
| 112 |
return UserMessage(
|
| 113 |
content=EmbeddedResource(
|
| 114 |
type="resource",
|
tests/prompts/test_prompt_manager.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import pytest
|
| 2 |
|
|
|
|
| 3 |
from fastmcp.prompts import Prompt
|
| 4 |
from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
|
| 5 |
from fastmcp.prompts.prompt_manager import PromptManager
|
|
@@ -97,7 +98,7 @@ class TestPromptManager:
|
|
| 97 |
async def test_render_unknown_prompt(self):
|
| 98 |
"""Test rendering a non-existent prompt."""
|
| 99 |
manager = PromptManager()
|
| 100 |
-
with pytest.raises(
|
| 101 |
await manager.render_prompt("unknown")
|
| 102 |
|
| 103 |
@pytest.mark.anyio
|
|
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
+
from fastmcp.exceptions import PromptError
|
| 4 |
from fastmcp.prompts import Prompt
|
| 5 |
from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
|
| 6 |
from fastmcp.prompts.prompt_manager import PromptManager
|
|
|
|
| 98 |
async def test_render_unknown_prompt(self):
|
| 99 |
"""Test rendering a non-existent prompt."""
|
| 100 |
manager = PromptManager()
|
| 101 |
+
with pytest.raises(PromptError, match="Unknown prompt: unknown"):
|
| 102 |
await manager.render_prompt("unknown")
|
| 103 |
|
| 104 |
@pytest.mark.anyio
|
tests/resources/test_resource_manager.py
CHANGED
|
@@ -4,6 +4,7 @@ from tempfile import NamedTemporaryFile
|
|
| 4 |
import pytest
|
| 5 |
from pydantic import AnyUrl, FileUrl
|
| 6 |
|
|
|
|
| 7 |
from fastmcp.resources import (
|
| 8 |
FileResource,
|
| 9 |
FunctionResource,
|
|
@@ -156,7 +157,7 @@ class TestResourceManager:
|
|
| 156 |
async def test_get_unknown_resource(self):
|
| 157 |
"""Test getting a non-existent resource."""
|
| 158 |
manager = ResourceManager()
|
| 159 |
-
with pytest.raises(
|
| 160 |
await manager.get_resource(AnyUrl("unknown://test"))
|
| 161 |
|
| 162 |
def test_list_resources(self, temp_file: Path):
|
|
|
|
| 4 |
import pytest
|
| 5 |
from pydantic import AnyUrl, FileUrl
|
| 6 |
|
| 7 |
+
from fastmcp.exceptions import ResourceError
|
| 8 |
from fastmcp.resources import (
|
| 9 |
FileResource,
|
| 10 |
FunctionResource,
|
|
|
|
| 157 |
async def test_get_unknown_resource(self):
|
| 158 |
"""Test getting a non-existent resource."""
|
| 159 |
manager = ResourceManager()
|
| 160 |
+
with pytest.raises(ResourceError, match="Unknown resource"):
|
| 161 |
await manager.get_resource(AnyUrl("unknown://test"))
|
| 162 |
|
| 163 |
def test_list_resources(self, temp_file: Path):
|
tests/server/test_file_server.py
CHANGED
|
@@ -75,7 +75,7 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
|
|
| 75 |
|
| 76 |
@pytest.mark.anyio
|
| 77 |
async def test_list_resources(mcp: FastMCP):
|
| 78 |
-
resources = await mcp.
|
| 79 |
assert len(resources) == 4
|
| 80 |
|
| 81 |
assert [str(r.uri) for r in resources] == [
|
|
@@ -88,7 +88,7 @@ async def test_list_resources(mcp: FastMCP):
|
|
| 88 |
|
| 89 |
@pytest.mark.anyio
|
| 90 |
async def test_read_resource_dir(mcp: FastMCP):
|
| 91 |
-
res_iter = await mcp.
|
| 92 |
res_list = list(res_iter)
|
| 93 |
assert len(res_list) == 1
|
| 94 |
res = res_list[0]
|
|
@@ -105,7 +105,7 @@ async def test_read_resource_dir(mcp: FastMCP):
|
|
| 105 |
|
| 106 |
@pytest.mark.anyio
|
| 107 |
async def test_read_resource_file(mcp: FastMCP):
|
| 108 |
-
res_iter = await mcp.
|
| 109 |
res_list = list(res_iter)
|
| 110 |
assert len(res_list) == 1
|
| 111 |
res = res_list[0]
|
|
@@ -125,7 +125,7 @@ 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.
|
| 129 |
res_list = list(res_iter)
|
| 130 |
assert len(res_list) == 1
|
| 131 |
res = res_list[0]
|
|
|
|
| 75 |
|
| 76 |
@pytest.mark.anyio
|
| 77 |
async def test_list_resources(mcp: FastMCP):
|
| 78 |
+
resources = await mcp._mcp_list_resources()
|
| 79 |
assert len(resources) == 4
|
| 80 |
|
| 81 |
assert [str(r.uri) for r in resources] == [
|
|
|
|
| 88 |
|
| 89 |
@pytest.mark.anyio
|
| 90 |
async def test_read_resource_dir(mcp: FastMCP):
|
| 91 |
+
res_iter = await mcp._mcp_read_resource("dir://test_dir")
|
| 92 |
res_list = list(res_iter)
|
| 93 |
assert len(res_list) == 1
|
| 94 |
res = res_list[0]
|
|
|
|
| 105 |
|
| 106 |
@pytest.mark.anyio
|
| 107 |
async def test_read_resource_file(mcp: FastMCP):
|
| 108 |
+
res_iter = await mcp._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]
|
|
|
|
| 125 |
await mcp.call_tool(
|
| 126 |
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
|
| 127 |
)
|
| 128 |
+
res_iter = await mcp._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]
|
tests/server/test_openapi.py
CHANGED
|
@@ -117,7 +117,7 @@ class TestTools:
|
|
| 117 |
"""
|
| 118 |
By default, tools exclude GET methods
|
| 119 |
"""
|
| 120 |
-
tools = await fastmcp_server.
|
| 121 |
assert len(tools) == 2
|
| 122 |
|
| 123 |
assert tools[0].model_dump() == dict(
|
|
@@ -164,7 +164,7 @@ class TestTools:
|
|
| 164 |
assert len(response.json()) == 4
|
| 165 |
|
| 166 |
# Check that the user was created via MCP
|
| 167 |
-
user_response = await fastmcp_server.
|
| 168 |
"resource://openapi/get_user_users__user_id__get/4"
|
| 169 |
)
|
| 170 |
user = user_response[0].content
|
|
@@ -186,7 +186,7 @@ class TestTools:
|
|
| 186 |
assert dict(id=1, name="XYZ", active=True) in response.json()
|
| 187 |
|
| 188 |
# Check that the user was updated via MCP
|
| 189 |
-
user_response = await fastmcp_server.
|
| 190 |
"resource://openapi/get_user_users__user_id__get/1"
|
| 191 |
)
|
| 192 |
user = user_response[0].content
|
|
@@ -198,7 +198,7 @@ class TestResources:
|
|
| 198 |
"""
|
| 199 |
By default, resources exclude GET methods without parameters
|
| 200 |
"""
|
| 201 |
-
resources = await fastmcp_server.
|
| 202 |
assert len(resources) == 1
|
| 203 |
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
|
| 204 |
assert resources[0].name == "get_users_users_get"
|
|
@@ -212,7 +212,7 @@ class TestResources:
|
|
| 212 |
json_users = TypeAdapter(list[User]).dump_python(
|
| 213 |
sorted(users_db.values(), key=lambda x: x.id)
|
| 214 |
)
|
| 215 |
-
resource_response = await fastmcp_server.
|
| 216 |
"resource://openapi/get_users_users_get"
|
| 217 |
)
|
| 218 |
resource = resource_response[0].content
|
|
@@ -226,7 +226,7 @@ class TestResourceTemplates:
|
|
| 226 |
"""
|
| 227 |
By default, resource templates exclude GET methods without parameters
|
| 228 |
"""
|
| 229 |
-
resource_templates = await fastmcp_server.
|
| 230 |
assert len(resource_templates) == 1
|
| 231 |
assert resource_templates[0].name == "get_user_users__user_id__get"
|
| 232 |
assert (
|
|
@@ -241,7 +241,7 @@ class TestResourceTemplates:
|
|
| 241 |
The resource template created by the OpenAPI server should be the same as the original
|
| 242 |
"""
|
| 243 |
user_id = 2
|
| 244 |
-
resource_response = await fastmcp_server.
|
| 245 |
f"resource://openapi/get_user_users__user_id__get/{user_id}"
|
| 246 |
)
|
| 247 |
|
|
@@ -256,7 +256,7 @@ class TestPrompts:
|
|
| 256 |
"""
|
| 257 |
By default, there are no prompts.
|
| 258 |
"""
|
| 259 |
-
prompts = await fastmcp_server.
|
| 260 |
assert len(prompts) == 0
|
| 261 |
|
| 262 |
|
|
|
|
| 117 |
"""
|
| 118 |
By default, tools exclude GET methods
|
| 119 |
"""
|
| 120 |
+
tools = await fastmcp_server._mcp_list_tools()
|
| 121 |
assert len(tools) == 2
|
| 122 |
|
| 123 |
assert tools[0].model_dump() == dict(
|
|
|
|
| 164 |
assert len(response.json()) == 4
|
| 165 |
|
| 166 |
# Check that the user was created via MCP
|
| 167 |
+
user_response = await fastmcp_server._mcp_read_resource(
|
| 168 |
"resource://openapi/get_user_users__user_id__get/4"
|
| 169 |
)
|
| 170 |
user = user_response[0].content
|
|
|
|
| 186 |
assert dict(id=1, name="XYZ", active=True) in response.json()
|
| 187 |
|
| 188 |
# Check that the user was updated via MCP
|
| 189 |
+
user_response = await fastmcp_server._mcp_read_resource(
|
| 190 |
"resource://openapi/get_user_users__user_id__get/1"
|
| 191 |
)
|
| 192 |
user = user_response[0].content
|
|
|
|
| 198 |
"""
|
| 199 |
By default, resources exclude GET methods without parameters
|
| 200 |
"""
|
| 201 |
+
resources = await fastmcp_server._mcp_list_resources()
|
| 202 |
assert len(resources) == 1
|
| 203 |
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
|
| 204 |
assert resources[0].name == "get_users_users_get"
|
|
|
|
| 212 |
json_users = TypeAdapter(list[User]).dump_python(
|
| 213 |
sorted(users_db.values(), key=lambda x: x.id)
|
| 214 |
)
|
| 215 |
+
resource_response = await fastmcp_server._mcp_read_resource(
|
| 216 |
"resource://openapi/get_users_users_get"
|
| 217 |
)
|
| 218 |
resource = resource_response[0].content
|
|
|
|
| 226 |
"""
|
| 227 |
By default, resource templates exclude GET methods without parameters
|
| 228 |
"""
|
| 229 |
+
resource_templates = await fastmcp_server._mcp_list_resource_templates()
|
| 230 |
assert len(resource_templates) == 1
|
| 231 |
assert resource_templates[0].name == "get_user_users__user_id__get"
|
| 232 |
assert (
|
|
|
|
| 241 |
The resource template created by the OpenAPI server should be the same as the original
|
| 242 |
"""
|
| 243 |
user_id = 2
|
| 244 |
+
resource_response = await fastmcp_server._mcp_read_resource(
|
| 245 |
f"resource://openapi/get_user_users__user_id__get/{user_id}"
|
| 246 |
)
|
| 247 |
|
|
|
|
| 256 |
"""
|
| 257 |
By default, there are no prompts.
|
| 258 |
"""
|
| 259 |
+
prompts = await fastmcp_server._mcp_list_prompts()
|
| 260 |
assert len(prompts) == 0
|
| 261 |
|
| 262 |
|
tests/server/test_proxy.py
CHANGED
|
@@ -7,6 +7,7 @@ from dirty_equals import Contains
|
|
| 7 |
from fastmcp import FastMCP
|
| 8 |
from fastmcp.client import Client
|
| 9 |
from fastmcp.client.transports import FastMCPTransport
|
|
|
|
| 10 |
from fastmcp.server.proxy import FastMCPProxy
|
| 11 |
|
| 12 |
USERS = [
|
|
@@ -80,11 +81,14 @@ async def test_create_proxy(fastmcp_server):
|
|
| 80 |
|
| 81 |
class TestTools:
|
| 82 |
async def test_list_tools(self, proxy_server):
|
| 83 |
-
tools =
|
| 84 |
assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
|
| 85 |
|
| 86 |
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
|
| 87 |
-
assert
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
async def test_call_tool_result_same_as_original(
|
| 90 |
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
|
@@ -106,19 +110,20 @@ class TestTools:
|
|
| 106 |
|
| 107 |
class TestResources:
|
| 108 |
async def test_list_resources(self, proxy_server):
|
| 109 |
-
resources =
|
| 110 |
assert [r.name for r in resources] == Contains(
|
| 111 |
"data://users", "resource://wave"
|
| 112 |
)
|
| 113 |
|
| 114 |
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
|
| 115 |
assert (
|
| 116 |
-
await proxy_server.
|
|
|
|
| 117 |
)
|
| 118 |
|
| 119 |
async def test_read_resource(self, proxy_server: FastMCPProxy):
|
| 120 |
result = await proxy_server.read_resource("resource://wave")
|
| 121 |
-
assert result
|
| 122 |
|
| 123 |
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
|
| 124 |
result = await fastmcp_server.read_resource("resource://wave")
|
|
@@ -127,31 +132,31 @@ class TestResources:
|
|
| 127 |
|
| 128 |
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
|
| 129 |
result = await proxy_server.read_resource("data://users")
|
| 130 |
-
assert json.loads(result
|
| 131 |
|
| 132 |
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
|
| 133 |
with pytest.raises(
|
| 134 |
-
|
| 135 |
):
|
| 136 |
await proxy_server.read_resource("resource://nonexistent")
|
| 137 |
|
| 138 |
|
| 139 |
class TestResourceTemplates:
|
| 140 |
async def test_list_resource_templates(self, proxy_server):
|
| 141 |
-
templates =
|
| 142 |
assert [t.name for t in templates] == Contains("get_user")
|
| 143 |
|
| 144 |
async def test_list_resource_templates_same_as_original(
|
| 145 |
self, fastmcp_server, proxy_server
|
| 146 |
):
|
| 147 |
-
result = await fastmcp_server.
|
| 148 |
-
proxy_result = await proxy_server.
|
| 149 |
assert proxy_result == result
|
| 150 |
|
| 151 |
@pytest.mark.parametrize("id", [1, 2, 3])
|
| 152 |
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
|
| 153 |
result = await proxy_server.read_resource(f"data://user/{id}")
|
| 154 |
-
assert json.loads(result
|
| 155 |
|
| 156 |
async def test_read_resource_template_same_as_original(
|
| 157 |
self, fastmcp_server, proxy_server
|
|
@@ -163,11 +168,14 @@ class TestResourceTemplates:
|
|
| 163 |
|
| 164 |
class TestPrompts:
|
| 165 |
async def test_list_prompts(self, proxy_server):
|
| 166 |
-
prompts =
|
| 167 |
assert [p.name for p in prompts] == Contains("welcome")
|
| 168 |
|
| 169 |
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
|
| 170 |
-
assert
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
async def test_render_prompt_same_as_original(
|
| 173 |
self, fastmcp_server: FastMCP, proxy_server
|
|
@@ -178,4 +186,4 @@ class TestPrompts:
|
|
| 178 |
|
| 179 |
async def test_render_prompt_calls_prompt(self, proxy_server):
|
| 180 |
result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
|
| 181 |
-
assert result
|
|
|
|
| 7 |
from fastmcp import FastMCP
|
| 8 |
from fastmcp.client import Client
|
| 9 |
from fastmcp.client.transports import FastMCPTransport
|
| 10 |
+
from fastmcp.exceptions import ResourceError
|
| 11 |
from fastmcp.server.proxy import FastMCPProxy
|
| 12 |
|
| 13 |
USERS = [
|
|
|
|
| 81 |
|
| 82 |
class TestTools:
|
| 83 |
async def test_list_tools(self, proxy_server):
|
| 84 |
+
tools = proxy_server.list_tools()
|
| 85 |
assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
|
| 86 |
|
| 87 |
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
|
| 88 |
+
assert (
|
| 89 |
+
await proxy_server._mcp_list_tools()
|
| 90 |
+
== await fastmcp_server._mcp_list_tools()
|
| 91 |
+
)
|
| 92 |
|
| 93 |
async def test_call_tool_result_same_as_original(
|
| 94 |
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
|
|
|
| 110 |
|
| 111 |
class TestResources:
|
| 112 |
async def test_list_resources(self, proxy_server):
|
| 113 |
+
resources = proxy_server.list_resources()
|
| 114 |
assert [r.name for r in resources] == Contains(
|
| 115 |
"data://users", "resource://wave"
|
| 116 |
)
|
| 117 |
|
| 118 |
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
|
| 119 |
assert (
|
| 120 |
+
await proxy_server._mcp_list_resources()
|
| 121 |
+
== await fastmcp_server._mcp_list_resources()
|
| 122 |
)
|
| 123 |
|
| 124 |
async def test_read_resource(self, proxy_server: FastMCPProxy):
|
| 125 |
result = await proxy_server.read_resource("resource://wave")
|
| 126 |
+
assert result == "👋"
|
| 127 |
|
| 128 |
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
|
| 129 |
result = await fastmcp_server.read_resource("resource://wave")
|
|
|
|
| 132 |
|
| 133 |
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
|
| 134 |
result = await proxy_server.read_resource("data://users")
|
| 135 |
+
assert json.loads(result) == USERS
|
| 136 |
|
| 137 |
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
|
| 138 |
with pytest.raises(
|
| 139 |
+
ResourceError, match="Unknown resource: resource://nonexistent"
|
| 140 |
):
|
| 141 |
await proxy_server.read_resource("resource://nonexistent")
|
| 142 |
|
| 143 |
|
| 144 |
class TestResourceTemplates:
|
| 145 |
async def test_list_resource_templates(self, proxy_server):
|
| 146 |
+
templates = proxy_server.list_resource_templates()
|
| 147 |
assert [t.name for t in templates] == Contains("get_user")
|
| 148 |
|
| 149 |
async def test_list_resource_templates_same_as_original(
|
| 150 |
self, fastmcp_server, proxy_server
|
| 151 |
):
|
| 152 |
+
result = await fastmcp_server._mcp_list_resource_templates()
|
| 153 |
+
proxy_result = await proxy_server._mcp_list_resource_templates()
|
| 154 |
assert proxy_result == result
|
| 155 |
|
| 156 |
@pytest.mark.parametrize("id", [1, 2, 3])
|
| 157 |
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
|
| 158 |
result = await proxy_server.read_resource(f"data://user/{id}")
|
| 159 |
+
assert json.loads(result) == USERS[id - 1]
|
| 160 |
|
| 161 |
async def test_read_resource_template_same_as_original(
|
| 162 |
self, fastmcp_server, proxy_server
|
|
|
|
| 168 |
|
| 169 |
class TestPrompts:
|
| 170 |
async def test_list_prompts(self, proxy_server):
|
| 171 |
+
prompts = proxy_server.list_prompts()
|
| 172 |
assert [p.name for p in prompts] == Contains("welcome")
|
| 173 |
|
| 174 |
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
|
| 175 |
+
assert (
|
| 176 |
+
await proxy_server._mcp_list_prompts()
|
| 177 |
+
== await fastmcp_server._mcp_list_prompts()
|
| 178 |
+
)
|
| 179 |
|
| 180 |
async def test_render_prompt_same_as_original(
|
| 181 |
self, fastmcp_server: FastMCP, proxy_server
|
|
|
|
| 186 |
|
| 187 |
async def test_render_prompt_calls_prompt(self, proxy_server):
|
| 188 |
result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
|
| 189 |
+
assert result[0].content.text == "Welcome to FastMCP, Alice!"
|
tests/server/test_server.py
CHANGED
|
@@ -14,7 +14,7 @@ from mcp.types import (
|
|
| 14 |
from pydantic import AnyUrl, Field
|
| 15 |
|
| 16 |
from fastmcp import Client, Context, FastMCP
|
| 17 |
-
from fastmcp.exceptions import ToolError
|
| 18 |
from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
|
| 19 |
from fastmcp.resources import FileResource, FunctionResource
|
| 20 |
from fastmcp.utilities.types import Image
|
|
@@ -56,16 +56,26 @@ class TestCreateServer:
|
|
| 56 |
assert isinstance(content, TextContent)
|
| 57 |
assert "¡Hola, 世界! 👋" == content.text
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
mcp = FastMCP()
|
| 61 |
|
| 62 |
@mcp.tool()
|
| 63 |
def add(x: int, y: int) -> int:
|
| 64 |
return x + y
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
async def
|
| 69 |
mcp = FastMCP()
|
| 70 |
|
| 71 |
with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
|
|
@@ -74,16 +84,145 @@ class TestCreateServer:
|
|
| 74 |
def add(x: int, y: int) -> int:
|
| 75 |
return x + y
|
| 76 |
|
| 77 |
-
async def
|
| 78 |
mcp = FastMCP()
|
| 79 |
|
| 80 |
-
@mcp.
|
| 81 |
-
def
|
| 82 |
-
return
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
mcp = FastMCP()
|
| 88 |
|
| 89 |
with pytest.raises(
|
|
@@ -91,8 +230,386 @@ class TestCreateServer:
|
|
| 91 |
):
|
| 92 |
|
| 93 |
@mcp.resource # Missing parentheses #type: ignore
|
| 94 |
-
def get_data(
|
| 95 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
@pytest.fixture
|
|
@@ -136,10 +653,10 @@ def tool_server():
|
|
| 136 |
|
| 137 |
class TestServerTools:
|
| 138 |
async def test_add_tool_exists(self, tool_server: FastMCP):
|
| 139 |
-
assert "add" in [t.name for t in await tool_server.
|
| 140 |
|
| 141 |
async def test_list_tools(self, tool_server: FastMCP):
|
| 142 |
-
assert len(await tool_server.
|
| 143 |
|
| 144 |
async def test_call_tool(self, tool_server: FastMCP):
|
| 145 |
result = await tool_server.call_tool("add", {"x": 1, "y": 2})
|
|
@@ -236,7 +753,7 @@ class TestServerTools:
|
|
| 236 |
"""A greeting tool"""
|
| 237 |
return f"Hello {title} {name}"
|
| 238 |
|
| 239 |
-
tools = await mcp.
|
| 240 |
assert len(tools) == 1
|
| 241 |
tool = tools[0]
|
| 242 |
|
|
@@ -328,7 +845,7 @@ class TestServerResourceTemplates:
|
|
| 328 |
parameters don't match"""
|
| 329 |
mcp = FastMCP()
|
| 330 |
|
| 331 |
-
with pytest.raises(ValueError, match="
|
| 332 |
|
| 333 |
@mcp.resource("resource://data")
|
| 334 |
def get_data_fn(param: str) -> str:
|
|
@@ -338,7 +855,7 @@ class TestServerResourceTemplates:
|
|
| 338 |
"""Test that a resource with URI parameters is automatically a template"""
|
| 339 |
mcp = FastMCP()
|
| 340 |
|
| 341 |
-
with pytest.raises(ValueError, match="
|
| 342 |
|
| 343 |
@mcp.resource("resource://{param}")
|
| 344 |
def get_data() -> str:
|
|
@@ -422,7 +939,7 @@ class TestServerResourceTemplates:
|
|
| 422 |
|
| 423 |
# Should be registered as a template
|
| 424 |
assert len(mcp._resource_manager._templates) == 1
|
| 425 |
-
assert len(await mcp.
|
| 426 |
|
| 427 |
# When accessed, should create a concrete resource
|
| 428 |
resource = await mcp._resource_manager.get_resource("resource://test/data")
|
|
|
|
| 14 |
from pydantic import AnyUrl, Field
|
| 15 |
|
| 16 |
from fastmcp import Client, Context, FastMCP
|
| 17 |
+
from fastmcp.exceptions import ResourceError, ToolError
|
| 18 |
from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
|
| 19 |
from fastmcp.resources import FileResource, FunctionResource
|
| 20 |
from fastmcp.utilities.types import Image
|
|
|
|
| 56 |
assert isinstance(content, TextContent)
|
| 57 |
assert "¡Hola, 世界! 👋" == content.text
|
| 58 |
|
| 59 |
+
|
| 60 |
+
class TestToolDecorator:
|
| 61 |
+
async def test_no_tools_before_decorator(self):
|
| 62 |
+
mcp = FastMCP()
|
| 63 |
+
|
| 64 |
+
with pytest.raises(ToolError, match="Unknown tool: add"):
|
| 65 |
+
await mcp.call_tool("add", {"x": 1, "y": 2})
|
| 66 |
+
|
| 67 |
+
async def test_tool_decorator(self):
|
| 68 |
mcp = FastMCP()
|
| 69 |
|
| 70 |
@mcp.tool()
|
| 71 |
def add(x: int, y: int) -> int:
|
| 72 |
return x + y
|
| 73 |
|
| 74 |
+
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
| 75 |
+
assert isinstance(result[0], TextContent)
|
| 76 |
+
assert result[0].text == "3"
|
| 77 |
|
| 78 |
+
async def test_tool_decorator_incorrect_usage(self):
|
| 79 |
mcp = FastMCP()
|
| 80 |
|
| 81 |
with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
|
|
|
|
| 84 |
def add(x: int, y: int) -> int:
|
| 85 |
return x + y
|
| 86 |
|
| 87 |
+
async def test_tool_decorator_with_name(self):
|
| 88 |
mcp = FastMCP()
|
| 89 |
|
| 90 |
+
@mcp.tool(name="custom-add")
|
| 91 |
+
def add(x: int, y: int) -> int:
|
| 92 |
+
return x + y
|
| 93 |
|
| 94 |
+
result = await mcp.call_tool("custom-add", {"x": 1, "y": 2})
|
| 95 |
+
assert isinstance(result[0], TextContent)
|
| 96 |
+
assert result[0].text == "3"
|
| 97 |
+
|
| 98 |
+
async def test_tool_decorator_with_description(self):
|
| 99 |
+
mcp = FastMCP()
|
| 100 |
+
|
| 101 |
+
@mcp.tool(description="Add two numbers")
|
| 102 |
+
def add(x: int, y: int) -> int:
|
| 103 |
+
return x + y
|
| 104 |
+
|
| 105 |
+
tools = await mcp._mcp_list_tools()
|
| 106 |
+
assert len(tools) == 1
|
| 107 |
+
tool = tools[0]
|
| 108 |
+
assert tool.description == "Add two numbers"
|
| 109 |
+
|
| 110 |
+
async def test_tool_decorator_instance_method(self):
|
| 111 |
+
mcp = FastMCP()
|
| 112 |
+
|
| 113 |
+
class MyClass:
|
| 114 |
+
def __init__(self, x: int):
|
| 115 |
+
self.x = x
|
| 116 |
+
|
| 117 |
+
@mcp.tool()
|
| 118 |
+
def add(self, y: int) -> int:
|
| 119 |
+
return self.x + y
|
| 120 |
+
|
| 121 |
+
obj = MyClass(10)
|
| 122 |
+
mcp.add_tool(obj.add)
|
| 123 |
+
result = await mcp.call_tool("add", {"y": 2})
|
| 124 |
+
assert isinstance(result[0], TextContent)
|
| 125 |
+
assert result[0].text == "12"
|
| 126 |
+
|
| 127 |
+
async def test_tool_decorator_classmethod(self):
|
| 128 |
+
mcp = FastMCP()
|
| 129 |
+
|
| 130 |
+
class MyClass:
|
| 131 |
+
x: int = 10
|
| 132 |
+
|
| 133 |
+
@classmethod
|
| 134 |
+
def add(cls, y: int) -> int:
|
| 135 |
+
return cls.x + y
|
| 136 |
+
|
| 137 |
+
mcp.add_tool(MyClass.add)
|
| 138 |
+
result = await mcp.call_tool("add", {"y": 2})
|
| 139 |
+
assert isinstance(result[0], TextContent)
|
| 140 |
+
assert result[0].text == "12"
|
| 141 |
+
|
| 142 |
+
async def test_tool_decorator_staticmethod(self):
|
| 143 |
+
mcp = FastMCP()
|
| 144 |
+
|
| 145 |
+
class MyClass:
|
| 146 |
+
@staticmethod
|
| 147 |
+
@mcp.tool()
|
| 148 |
+
def add(x: int, y: int) -> int:
|
| 149 |
+
return x + y
|
| 150 |
+
|
| 151 |
+
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
| 152 |
+
assert isinstance(result[0], TextContent)
|
| 153 |
+
assert result[0].text == "3"
|
| 154 |
+
|
| 155 |
+
async def test_tool_decorator_async_function(self):
|
| 156 |
+
mcp = FastMCP()
|
| 157 |
+
|
| 158 |
+
@mcp.tool()
|
| 159 |
+
async def add(x: int, y: int) -> int:
|
| 160 |
+
return x + y
|
| 161 |
+
|
| 162 |
+
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
| 163 |
+
assert isinstance(result[0], TextContent)
|
| 164 |
+
assert result[0].text == "3"
|
| 165 |
+
|
| 166 |
+
async def test_tool_decorator_classmethod_async_function(self):
|
| 167 |
+
mcp = FastMCP()
|
| 168 |
+
|
| 169 |
+
class MyClass:
|
| 170 |
+
x = 10
|
| 171 |
+
|
| 172 |
+
@classmethod
|
| 173 |
+
async def add(cls, y: int) -> int:
|
| 174 |
+
return cls.x + y
|
| 175 |
|
| 176 |
+
mcp.add_tool(MyClass.add)
|
| 177 |
+
result = await mcp.call_tool("add", {"y": 2})
|
| 178 |
+
assert isinstance(result[0], TextContent)
|
| 179 |
+
assert result[0].text == "12"
|
| 180 |
+
|
| 181 |
+
async def test_tool_decorator_staticmethod_async_function(self):
|
| 182 |
+
mcp = FastMCP()
|
| 183 |
+
|
| 184 |
+
class MyClass:
|
| 185 |
+
@staticmethod
|
| 186 |
+
async def add(x: int, y: int) -> int:
|
| 187 |
+
return x + y
|
| 188 |
+
|
| 189 |
+
mcp.add_tool(MyClass.add)
|
| 190 |
+
result = await mcp.call_tool("add", {"x": 1, "y": 2})
|
| 191 |
+
assert isinstance(result[0], TextContent)
|
| 192 |
+
assert result[0].text == "3"
|
| 193 |
+
|
| 194 |
+
async def test_tool_decorator_with_tags(self):
|
| 195 |
+
"""Test that the tool decorator properly sets tags."""
|
| 196 |
+
mcp = FastMCP()
|
| 197 |
+
|
| 198 |
+
@mcp.tool(tags={"example", "test-tag"})
|
| 199 |
+
def sample_tool(x: int) -> int:
|
| 200 |
+
return x * 2
|
| 201 |
+
|
| 202 |
+
# Verify the tags were set correctly
|
| 203 |
+
tools = mcp._tool_manager.list_tools()
|
| 204 |
+
assert len(tools) == 1
|
| 205 |
+
assert tools[0].tags == {"example", "test-tag"}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class TestResourceDecorator:
|
| 209 |
+
async def test_no_resources_before_decorator(self):
|
| 210 |
+
mcp = FastMCP()
|
| 211 |
+
|
| 212 |
+
with pytest.raises(ResourceError, match="Unknown resource"):
|
| 213 |
+
await mcp.read_resource("resource://data")
|
| 214 |
+
|
| 215 |
+
async def test_resource_decorator(self):
|
| 216 |
+
mcp = FastMCP()
|
| 217 |
+
|
| 218 |
+
@mcp.resource("resource://data")
|
| 219 |
+
def get_data() -> str:
|
| 220 |
+
return "Hello, world!"
|
| 221 |
+
|
| 222 |
+
result = await mcp.read_resource("resource://data")
|
| 223 |
+
assert result == "Hello, world!"
|
| 224 |
+
|
| 225 |
+
async def test_resource_decorator_incorrect_usage(self):
|
| 226 |
mcp = FastMCP()
|
| 227 |
|
| 228 |
with pytest.raises(
|
|
|
|
| 230 |
):
|
| 231 |
|
| 232 |
@mcp.resource # Missing parentheses #type: ignore
|
| 233 |
+
def get_data() -> str:
|
| 234 |
+
return "Hello, world!"
|
| 235 |
+
|
| 236 |
+
async def test_resource_decorator_with_name(self):
|
| 237 |
+
mcp = FastMCP()
|
| 238 |
+
|
| 239 |
+
@mcp.resource("resource://data", name="custom-data")
|
| 240 |
+
def get_data() -> str:
|
| 241 |
+
return "Hello, world!"
|
| 242 |
+
|
| 243 |
+
resources = mcp.list_resources()
|
| 244 |
+
assert len(resources) == 1
|
| 245 |
+
assert resources[0].name == "custom-data"
|
| 246 |
+
|
| 247 |
+
result = await mcp.read_resource("resource://data")
|
| 248 |
+
assert result == "Hello, world!"
|
| 249 |
+
|
| 250 |
+
async def test_resource_decorator_with_description(self):
|
| 251 |
+
mcp = FastMCP()
|
| 252 |
+
|
| 253 |
+
@mcp.resource("resource://data", description="Data resource")
|
| 254 |
+
def get_data() -> str:
|
| 255 |
+
return "Hello, world!"
|
| 256 |
+
|
| 257 |
+
resources = mcp.list_resources()
|
| 258 |
+
assert len(resources) == 1
|
| 259 |
+
assert resources[0].description == "Data resource"
|
| 260 |
+
|
| 261 |
+
async def test_resource_decorator_instance_method(self):
|
| 262 |
+
mcp = FastMCP()
|
| 263 |
+
|
| 264 |
+
class MyClass:
|
| 265 |
+
def __init__(self, prefix: str):
|
| 266 |
+
self.prefix = prefix
|
| 267 |
+
|
| 268 |
+
def get_data(self) -> str:
|
| 269 |
+
return f"{self.prefix} Hello, world!"
|
| 270 |
+
|
| 271 |
+
obj = MyClass("My prefix:")
|
| 272 |
+
mcp.add_resource_from_fn(
|
| 273 |
+
obj.get_data, uri="resource://data", name="instance-resource"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
result = await mcp.read_resource("resource://data")
|
| 277 |
+
assert result == "My prefix: Hello, world!"
|
| 278 |
+
|
| 279 |
+
async def test_resource_decorator_classmethod(self):
|
| 280 |
+
mcp = FastMCP()
|
| 281 |
+
|
| 282 |
+
class MyClass:
|
| 283 |
+
prefix = "Class prefix:"
|
| 284 |
+
|
| 285 |
+
@classmethod
|
| 286 |
+
def get_data(cls) -> str:
|
| 287 |
+
return f"{cls.prefix} Hello, world!"
|
| 288 |
+
|
| 289 |
+
mcp.add_resource_from_fn(
|
| 290 |
+
MyClass.get_data, uri="resource://data", name="class-resource"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
result = await mcp.read_resource("resource://data")
|
| 294 |
+
assert result == "Class prefix: Hello, world!"
|
| 295 |
+
|
| 296 |
+
async def test_resource_decorator_staticmethod(self):
|
| 297 |
+
mcp = FastMCP()
|
| 298 |
+
|
| 299 |
+
class MyClass:
|
| 300 |
+
@staticmethod
|
| 301 |
+
@mcp.resource("resource://data")
|
| 302 |
+
def get_data() -> str:
|
| 303 |
+
return "Static Hello, world!"
|
| 304 |
+
|
| 305 |
+
result = await mcp.read_resource("resource://data")
|
| 306 |
+
assert result == "Static Hello, world!"
|
| 307 |
+
|
| 308 |
+
async def test_resource_decorator_async_function(self):
|
| 309 |
+
mcp = FastMCP()
|
| 310 |
+
|
| 311 |
+
@mcp.resource("resource://data")
|
| 312 |
+
async def get_data() -> str:
|
| 313 |
+
return "Async Hello, world!"
|
| 314 |
+
|
| 315 |
+
result = await mcp.read_resource("resource://data")
|
| 316 |
+
assert result == "Async Hello, world!"
|
| 317 |
+
|
| 318 |
+
async def test_resource_decorator_with_tags(self):
|
| 319 |
+
mcp = FastMCP()
|
| 320 |
+
|
| 321 |
+
@mcp.resource("resource://data", tags={"example", "test-tag"})
|
| 322 |
+
def get_data() -> str:
|
| 323 |
+
return "Hello, world!"
|
| 324 |
+
|
| 325 |
+
resources = mcp.list_resources()
|
| 326 |
+
assert len(resources) == 1
|
| 327 |
+
assert resources[0].tags == {"example", "test-tag"}
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
class TestTemplateDecorator:
|
| 331 |
+
async def test_template_decorator(self):
|
| 332 |
+
mcp = FastMCP()
|
| 333 |
+
|
| 334 |
+
@mcp.resource("resource://{name}/data")
|
| 335 |
+
def get_data(name: str) -> str:
|
| 336 |
+
return f"Data for {name}"
|
| 337 |
+
|
| 338 |
+
templates = mcp.list_resource_templates()
|
| 339 |
+
assert len(templates) == 1
|
| 340 |
+
assert templates[0].uri_template == "resource://{name}/data"
|
| 341 |
+
|
| 342 |
+
result = await mcp.read_resource("resource://test/data")
|
| 343 |
+
assert result == "Data for test"
|
| 344 |
+
|
| 345 |
+
async def test_template_decorator_incorrect_usage(self):
|
| 346 |
+
mcp = FastMCP()
|
| 347 |
+
|
| 348 |
+
with pytest.raises(
|
| 349 |
+
TypeError, match="The @resource decorator was used incorrectly"
|
| 350 |
+
):
|
| 351 |
+
|
| 352 |
+
@mcp.resource # Missing parentheses #type: ignore
|
| 353 |
+
def get_data(name: str) -> str:
|
| 354 |
+
return f"Data for {name}"
|
| 355 |
+
|
| 356 |
+
async def test_template_decorator_with_name(self):
|
| 357 |
+
mcp = FastMCP()
|
| 358 |
+
|
| 359 |
+
@mcp.resource("resource://{name}/data", name="custom-template")
|
| 360 |
+
def get_data(name: str) -> str:
|
| 361 |
+
return f"Data for {name}"
|
| 362 |
+
|
| 363 |
+
templates = mcp.list_resource_templates()
|
| 364 |
+
assert len(templates) == 1
|
| 365 |
+
assert templates[0].name == "custom-template"
|
| 366 |
+
|
| 367 |
+
result = await mcp.read_resource("resource://test/data")
|
| 368 |
+
assert result == "Data for test"
|
| 369 |
+
|
| 370 |
+
async def test_template_decorator_with_description(self):
|
| 371 |
+
mcp = FastMCP()
|
| 372 |
+
|
| 373 |
+
@mcp.resource("resource://{name}/data", description="Template description")
|
| 374 |
+
def get_data(name: str) -> str:
|
| 375 |
+
return f"Data for {name}"
|
| 376 |
+
|
| 377 |
+
templates = mcp.list_resource_templates()
|
| 378 |
+
assert len(templates) == 1
|
| 379 |
+
assert templates[0].description == "Template description"
|
| 380 |
+
|
| 381 |
+
async def test_template_decorator_instance_method(self):
|
| 382 |
+
mcp = FastMCP()
|
| 383 |
+
|
| 384 |
+
class MyClass:
|
| 385 |
+
def __init__(self, prefix: str):
|
| 386 |
+
self.prefix = prefix
|
| 387 |
+
|
| 388 |
+
def get_data(self, name: str) -> str:
|
| 389 |
+
return f"{self.prefix} Data for {name}"
|
| 390 |
+
|
| 391 |
+
obj = MyClass("My prefix:")
|
| 392 |
+
|
| 393 |
+
mcp.add_resource_from_fn(
|
| 394 |
+
obj.get_data, uri="resource://{name}/data", name="instance-template"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
result = await mcp.read_resource("resource://test/data")
|
| 398 |
+
assert result == "My prefix: Data for test"
|
| 399 |
+
|
| 400 |
+
async def test_template_decorator_classmethod(self):
|
| 401 |
+
mcp = FastMCP()
|
| 402 |
+
|
| 403 |
+
class MyClass:
|
| 404 |
+
prefix = "Class prefix:"
|
| 405 |
+
|
| 406 |
+
@classmethod
|
| 407 |
+
def get_data(cls, name: str) -> str:
|
| 408 |
+
return f"{cls.prefix} Data for {name}"
|
| 409 |
+
|
| 410 |
+
mcp.add_resource_from_fn(
|
| 411 |
+
MyClass.get_data, uri="resource://{name}/data", name="class-template"
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
result = await mcp.read_resource("resource://test/data")
|
| 415 |
+
assert result == "Class prefix: Data for test"
|
| 416 |
+
|
| 417 |
+
async def test_template_decorator_staticmethod(self):
|
| 418 |
+
mcp = FastMCP()
|
| 419 |
+
|
| 420 |
+
class MyClass:
|
| 421 |
+
@staticmethod
|
| 422 |
+
@mcp.resource("resource://{name}/data")
|
| 423 |
+
def get_data(name: str) -> str:
|
| 424 |
+
return f"Static Data for {name}"
|
| 425 |
+
|
| 426 |
+
result = await mcp.read_resource("resource://test/data")
|
| 427 |
+
assert result == "Static Data for test"
|
| 428 |
+
|
| 429 |
+
async def test_template_decorator_async_function(self):
|
| 430 |
+
mcp = FastMCP()
|
| 431 |
+
|
| 432 |
+
@mcp.resource("resource://{name}/data")
|
| 433 |
+
async def get_data(name: str) -> str:
|
| 434 |
+
return f"Async Data for {name}"
|
| 435 |
+
|
| 436 |
+
result = await mcp.read_resource("resource://test/data")
|
| 437 |
+
assert result == "Async Data for test"
|
| 438 |
+
|
| 439 |
+
async def test_template_decorator_with_tags(self):
|
| 440 |
+
mcp = FastMCP()
|
| 441 |
+
|
| 442 |
+
@mcp.resource("resource://{name}/data", tags={"template", "test-tag"})
|
| 443 |
+
def get_data(name: str) -> str:
|
| 444 |
+
return f"Data for {name}"
|
| 445 |
+
|
| 446 |
+
templates = mcp.list_resource_templates()
|
| 447 |
+
assert len(templates) == 1
|
| 448 |
+
assert templates[0].tags == {"template", "test-tag"}
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
class TestPromptDecorator:
|
| 452 |
+
async def test_prompt_decorator(self):
|
| 453 |
+
mcp = FastMCP()
|
| 454 |
+
|
| 455 |
+
@mcp.prompt()
|
| 456 |
+
def test_prompt() -> str:
|
| 457 |
+
return "Hello, world!"
|
| 458 |
+
|
| 459 |
+
prompts = mcp.list_prompts()
|
| 460 |
+
assert len(prompts) == 1
|
| 461 |
+
assert prompts[0].name == "test_prompt"
|
| 462 |
+
|
| 463 |
+
result = await mcp.get_prompt("test_prompt")
|
| 464 |
+
assert len(result) == 1
|
| 465 |
+
message = result[0]
|
| 466 |
+
assert isinstance(message.content, TextContent)
|
| 467 |
+
assert message.content.text == "Hello, world!"
|
| 468 |
+
|
| 469 |
+
async def test_prompt_decorator_incorrect_usage(self):
|
| 470 |
+
mcp = FastMCP()
|
| 471 |
+
|
| 472 |
+
with pytest.raises(
|
| 473 |
+
TypeError, match="The @prompt decorator was used incorrectly"
|
| 474 |
+
):
|
| 475 |
+
|
| 476 |
+
@mcp.prompt # Missing parentheses #type: ignore
|
| 477 |
+
def test_prompt() -> str:
|
| 478 |
+
return "Hello, world!"
|
| 479 |
+
|
| 480 |
+
async def test_prompt_decorator_with_name(self):
|
| 481 |
+
mcp = FastMCP()
|
| 482 |
+
|
| 483 |
+
@mcp.prompt(name="custom-prompt")
|
| 484 |
+
def test_prompt() -> str:
|
| 485 |
+
return "Hello, world!"
|
| 486 |
+
|
| 487 |
+
prompts = mcp.list_prompts()
|
| 488 |
+
assert len(prompts) == 1
|
| 489 |
+
assert prompts[0].name == "custom-prompt"
|
| 490 |
+
|
| 491 |
+
result = await mcp.get_prompt("custom-prompt")
|
| 492 |
+
assert len(result) == 1
|
| 493 |
+
message = result[0]
|
| 494 |
+
assert isinstance(message.content, TextContent)
|
| 495 |
+
assert message.content.text == "Hello, world!"
|
| 496 |
+
|
| 497 |
+
async def test_prompt_decorator_with_description(self):
|
| 498 |
+
mcp = FastMCP()
|
| 499 |
+
|
| 500 |
+
@mcp.prompt(description="Test prompt description")
|
| 501 |
+
def test_prompt() -> str:
|
| 502 |
+
return "Hello, world!"
|
| 503 |
+
|
| 504 |
+
prompts = mcp.list_prompts()
|
| 505 |
+
assert len(prompts) == 1
|
| 506 |
+
assert prompts[0].description == "Test prompt description"
|
| 507 |
+
|
| 508 |
+
async def test_prompt_decorator_with_parameters(self):
|
| 509 |
+
mcp = FastMCP()
|
| 510 |
+
|
| 511 |
+
@mcp.prompt()
|
| 512 |
+
def test_prompt(name: str, greeting: str = "Hello") -> str:
|
| 513 |
+
return f"{greeting}, {name}!"
|
| 514 |
+
|
| 515 |
+
prompts = mcp.list_prompts()
|
| 516 |
+
assert len(prompts) == 1
|
| 517 |
+
assert prompts[0].arguments is not None
|
| 518 |
+
assert len(prompts[0].arguments) == 2
|
| 519 |
+
assert prompts[0].arguments[0].name == "name"
|
| 520 |
+
assert prompts[0].arguments[0].required is True
|
| 521 |
+
assert prompts[0].arguments[1].name == "greeting"
|
| 522 |
+
assert prompts[0].arguments[1].required is False
|
| 523 |
+
|
| 524 |
+
result = await mcp.get_prompt("test_prompt", {"name": "World"})
|
| 525 |
+
assert len(result) == 1
|
| 526 |
+
message = result[0]
|
| 527 |
+
assert isinstance(message.content, TextContent)
|
| 528 |
+
assert message.content.text == "Hello, World!"
|
| 529 |
+
|
| 530 |
+
result = await mcp.get_prompt(
|
| 531 |
+
"test_prompt", {"name": "World", "greeting": "Hi"}
|
| 532 |
+
)
|
| 533 |
+
assert len(result) == 1
|
| 534 |
+
message = result[0]
|
| 535 |
+
assert isinstance(message.content, TextContent)
|
| 536 |
+
assert message.content.text == "Hi, World!"
|
| 537 |
+
|
| 538 |
+
async def test_prompt_decorator_instance_method(self):
|
| 539 |
+
mcp = FastMCP()
|
| 540 |
+
|
| 541 |
+
class MyClass:
|
| 542 |
+
def __init__(self, prefix: str):
|
| 543 |
+
self.prefix = prefix
|
| 544 |
+
|
| 545 |
+
def test_prompt(self) -> str:
|
| 546 |
+
return f"{self.prefix} Hello, world!"
|
| 547 |
+
|
| 548 |
+
obj = MyClass("My prefix:")
|
| 549 |
+
mcp.add_prompt(obj.test_prompt, name="test_prompt")
|
| 550 |
+
|
| 551 |
+
result = await mcp.get_prompt("test_prompt")
|
| 552 |
+
assert len(result) == 1
|
| 553 |
+
message = result[0]
|
| 554 |
+
assert isinstance(message.content, TextContent)
|
| 555 |
+
assert message.content.text == "My prefix: Hello, world!"
|
| 556 |
+
|
| 557 |
+
async def test_prompt_decorator_classmethod(self):
|
| 558 |
+
mcp = FastMCP()
|
| 559 |
+
|
| 560 |
+
class MyClass:
|
| 561 |
+
prefix = "Class prefix:"
|
| 562 |
+
|
| 563 |
+
@classmethod
|
| 564 |
+
def test_prompt(cls) -> str:
|
| 565 |
+
return f"{cls.prefix} Hello, world!"
|
| 566 |
+
|
| 567 |
+
mcp.add_prompt(MyClass.test_prompt, name="test_prompt")
|
| 568 |
+
|
| 569 |
+
result = await mcp.get_prompt("test_prompt")
|
| 570 |
+
assert len(result) == 1
|
| 571 |
+
message = result[0]
|
| 572 |
+
assert isinstance(message.content, TextContent)
|
| 573 |
+
assert message.content.text == "Class prefix: Hello, world!"
|
| 574 |
+
|
| 575 |
+
async def test_prompt_decorator_staticmethod(self):
|
| 576 |
+
mcp = FastMCP()
|
| 577 |
+
|
| 578 |
+
class MyClass:
|
| 579 |
+
@staticmethod
|
| 580 |
+
@mcp.prompt()
|
| 581 |
+
def test_prompt() -> str:
|
| 582 |
+
return "Static Hello, world!"
|
| 583 |
+
|
| 584 |
+
result = await mcp.get_prompt("test_prompt")
|
| 585 |
+
assert len(result) == 1
|
| 586 |
+
message = result[0]
|
| 587 |
+
assert isinstance(message.content, TextContent)
|
| 588 |
+
assert message.content.text == "Static Hello, world!"
|
| 589 |
+
|
| 590 |
+
async def test_prompt_decorator_async_function(self):
|
| 591 |
+
mcp = FastMCP()
|
| 592 |
+
|
| 593 |
+
@mcp.prompt()
|
| 594 |
+
async def test_prompt() -> str:
|
| 595 |
+
return "Async Hello, world!"
|
| 596 |
+
|
| 597 |
+
result = await mcp.get_prompt("test_prompt")
|
| 598 |
+
assert len(result) == 1
|
| 599 |
+
message = result[0]
|
| 600 |
+
assert isinstance(message.content, TextContent)
|
| 601 |
+
assert message.content.text == "Async Hello, world!"
|
| 602 |
+
|
| 603 |
+
async def test_prompt_decorator_with_tags(self):
|
| 604 |
+
mcp = FastMCP()
|
| 605 |
+
|
| 606 |
+
@mcp.prompt(tags={"example", "test-tag"})
|
| 607 |
+
def test_prompt() -> str:
|
| 608 |
+
return "Hello, world!"
|
| 609 |
+
|
| 610 |
+
prompts = mcp.list_prompts()
|
| 611 |
+
assert len(prompts) == 1
|
| 612 |
+
assert prompts[0].tags == {"example", "test-tag"}
|
| 613 |
|
| 614 |
|
| 615 |
@pytest.fixture
|
|
|
|
| 653 |
|
| 654 |
class TestServerTools:
|
| 655 |
async def test_add_tool_exists(self, tool_server: FastMCP):
|
| 656 |
+
assert "add" in [t.name for t in await tool_server._mcp_list_tools()]
|
| 657 |
|
| 658 |
async def test_list_tools(self, tool_server: FastMCP):
|
| 659 |
+
assert len(await tool_server._mcp_list_tools()) == 6
|
| 660 |
|
| 661 |
async def test_call_tool(self, tool_server: FastMCP):
|
| 662 |
result = await tool_server.call_tool("add", {"x": 1, "y": 2})
|
|
|
|
| 753 |
"""A greeting tool"""
|
| 754 |
return f"Hello {title} {name}"
|
| 755 |
|
| 756 |
+
tools = await mcp._mcp_list_tools()
|
| 757 |
assert len(tools) == 1
|
| 758 |
tool = tools[0]
|
| 759 |
|
|
|
|
| 845 |
parameters don't match"""
|
| 846 |
mcp = FastMCP()
|
| 847 |
|
| 848 |
+
with pytest.raises(ValueError, match="mismatch between URI parameters"):
|
| 849 |
|
| 850 |
@mcp.resource("resource://data")
|
| 851 |
def get_data_fn(param: str) -> str:
|
|
|
|
| 855 |
"""Test that a resource with URI parameters is automatically a template"""
|
| 856 |
mcp = FastMCP()
|
| 857 |
|
| 858 |
+
with pytest.raises(ValueError, match="mismatch between URI parameters"):
|
| 859 |
|
| 860 |
@mcp.resource("resource://{param}")
|
| 861 |
def get_data() -> str:
|
|
|
|
| 939 |
|
| 940 |
# Should be registered as a template
|
| 941 |
assert len(mcp._resource_manager._templates) == 1
|
| 942 |
+
assert len(await mcp._mcp_list_resources()) == 0
|
| 943 |
|
| 944 |
# When accessed, should create a concrete resource
|
| 945 |
resource = await mcp._resource_manager.get_resource("resource://test/data")
|
tests/utilities/test_decorated_function.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import functools
|
| 2 |
+
from collections.abc import Callable
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from fastmcp.utilities.decorators import DecoratedFunction
|
| 8 |
+
|
| 9 |
+
DECORATOR_CALLED = []
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def decorator(fn: Callable[..., Any]) -> DecoratedFunction[..., Any]:
|
| 13 |
+
@functools.wraps(fn)
|
| 14 |
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
| 15 |
+
DECORATOR_CALLED.append((args, kwargs))
|
| 16 |
+
return fn(*args, **kwargs)
|
| 17 |
+
|
| 18 |
+
return DecoratedFunction(wrapper)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture(autouse=True)
|
| 22 |
+
def reset_decorator_called():
|
| 23 |
+
DECORATOR_CALLED.clear()
|
| 24 |
+
yield
|
| 25 |
+
DECORATOR_CALLED.clear()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@decorator
|
| 29 |
+
def add(a: int, b: int) -> int:
|
| 30 |
+
return a + b
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@decorator
|
| 34 |
+
async def add_async(a: int, b: int) -> int:
|
| 35 |
+
return a + b
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class DecoratedClass:
|
| 39 |
+
def __init__(self, x: int):
|
| 40 |
+
self.x = x
|
| 41 |
+
|
| 42 |
+
@decorator
|
| 43 |
+
def add(self, a: int, b: int) -> int:
|
| 44 |
+
return a + b + self.x
|
| 45 |
+
|
| 46 |
+
@decorator
|
| 47 |
+
async def add_async(self, a: int, b: int) -> int:
|
| 48 |
+
return a + b + self.x
|
| 49 |
+
|
| 50 |
+
@classmethod
|
| 51 |
+
@decorator
|
| 52 |
+
def add_classmethod(cls, a: int, b: int) -> int:
|
| 53 |
+
return a + b
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
@decorator
|
| 57 |
+
def add_staticmethod(a: int, b: int) -> int:
|
| 58 |
+
return a + b
|
| 59 |
+
|
| 60 |
+
@classmethod
|
| 61 |
+
@decorator
|
| 62 |
+
async def add_classmethod_async(cls, a: int, b: int) -> int:
|
| 63 |
+
return a + b
|
| 64 |
+
|
| 65 |
+
@staticmethod
|
| 66 |
+
@decorator
|
| 67 |
+
async def add_staticmethod_async(a: int, b: int) -> int:
|
| 68 |
+
return a + b
|
| 69 |
+
|
| 70 |
+
@decorator
|
| 71 |
+
@classmethod
|
| 72 |
+
def add_classmethod_reverse_decorator_order(cls, a: int, b: int) -> int:
|
| 73 |
+
return a + b
|
| 74 |
+
|
| 75 |
+
@decorator
|
| 76 |
+
@staticmethod
|
| 77 |
+
def add_staticmethod_reverse_decorator_order(a: int, b: int) -> int:
|
| 78 |
+
return a + b
|
| 79 |
+
|
| 80 |
+
@decorator
|
| 81 |
+
@classmethod
|
| 82 |
+
async def add_classmethod_async_reverse_decorator_order(cls, a: int, b: int) -> int:
|
| 83 |
+
return a + b
|
| 84 |
+
|
| 85 |
+
@decorator
|
| 86 |
+
@staticmethod
|
| 87 |
+
async def add_staticmethod_async_reverse_decorator_order(a: int, b: int) -> int:
|
| 88 |
+
return a + b
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_add():
|
| 92 |
+
assert add(1, 2) == 3
|
| 93 |
+
assert DECORATOR_CALLED == [((1, 2), {})]
|
| 94 |
+
DECORATOR_CALLED.clear()
|
| 95 |
+
|
| 96 |
+
# Test with keyword arguments
|
| 97 |
+
assert add(a=3, b=4) == 7
|
| 98 |
+
assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def test_add_async():
|
| 102 |
+
assert await add_async(1, 2) == 3
|
| 103 |
+
assert DECORATOR_CALLED == [((1, 2), {})]
|
| 104 |
+
DECORATOR_CALLED.clear()
|
| 105 |
+
|
| 106 |
+
# Test with keyword arguments
|
| 107 |
+
assert await add_async(a=3, b=4) == 7
|
| 108 |
+
assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_instance_method():
|
| 112 |
+
obj = DecoratedClass(10)
|
| 113 |
+
assert obj.add(2, 3) == 15
|
| 114 |
+
assert DECORATOR_CALLED == [((obj, 2, 3), {})]
|
| 115 |
+
DECORATOR_CALLED.clear()
|
| 116 |
+
|
| 117 |
+
# Test with keyword arguments
|
| 118 |
+
assert obj.add(a=4, b=5) == 19
|
| 119 |
+
assert DECORATOR_CALLED == [((obj,), {"a": 4, "b": 5})]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def test_instance_method_async():
|
| 123 |
+
obj = DecoratedClass(10)
|
| 124 |
+
assert await obj.add_async(2, 3) == 15
|
| 125 |
+
assert DECORATOR_CALLED == [((obj, 2, 3), {})]
|
| 126 |
+
DECORATOR_CALLED.clear()
|
| 127 |
+
|
| 128 |
+
# Test with keyword arguments
|
| 129 |
+
assert await obj.add_async(a=4, b=5) == 19
|
| 130 |
+
assert DECORATOR_CALLED == [((obj,), {"a": 4, "b": 5})]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_classmethod():
|
| 134 |
+
assert DecoratedClass.add_classmethod(1, 2) == 3
|
| 135 |
+
assert DECORATOR_CALLED == [((DecoratedClass, 1, 2), {})]
|
| 136 |
+
DECORATOR_CALLED.clear()
|
| 137 |
+
|
| 138 |
+
# Test with keyword arguments
|
| 139 |
+
assert DecoratedClass.add_classmethod(a=3, b=4) == 7
|
| 140 |
+
assert DECORATOR_CALLED == [((DecoratedClass,), {"a": 3, "b": 4})]
|
| 141 |
+
DECORATOR_CALLED.clear()
|
| 142 |
+
|
| 143 |
+
# Test via instance
|
| 144 |
+
obj = DecoratedClass(10)
|
| 145 |
+
assert obj.add_classmethod(5, 6) == 11
|
| 146 |
+
assert DECORATOR_CALLED == [((DecoratedClass, 5, 6), {})]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def test_classmethod_async():
|
| 150 |
+
assert await DecoratedClass.add_classmethod_async(1, 2) == 3
|
| 151 |
+
assert DECORATOR_CALLED == [((DecoratedClass, 1, 2), {})]
|
| 152 |
+
DECORATOR_CALLED.clear()
|
| 153 |
+
|
| 154 |
+
# Test with keyword arguments
|
| 155 |
+
assert await DecoratedClass.add_classmethod_async(a=3, b=4) == 7
|
| 156 |
+
assert DECORATOR_CALLED == [((DecoratedClass,), {"a": 3, "b": 4})]
|
| 157 |
+
DECORATOR_CALLED.clear()
|
| 158 |
+
|
| 159 |
+
# Test via instance
|
| 160 |
+
obj = DecoratedClass(10)
|
| 161 |
+
assert await obj.add_classmethod_async(5, 6) == 11
|
| 162 |
+
assert DECORATOR_CALLED == [((DecoratedClass, 5, 6), {})]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_classmethod_wrong_order():
|
| 166 |
+
with pytest.raises(
|
| 167 |
+
TypeError,
|
| 168 |
+
match="To apply this decorator to a classmethod, apply the decorator first, then @classmethod on top.",
|
| 169 |
+
):
|
| 170 |
+
DecoratedClass.add_classmethod_reverse_decorator_order(1, 2)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_classmethod_async_wrong_order():
|
| 174 |
+
with pytest.raises(
|
| 175 |
+
TypeError,
|
| 176 |
+
match="To apply this decorator to a classmethod, apply the decorator first, then @classmethod on top.",
|
| 177 |
+
):
|
| 178 |
+
await DecoratedClass.add_classmethod_async_reverse_decorator_order(1, 2)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_staticmethod():
|
| 182 |
+
assert DecoratedClass.add_staticmethod(1, 2) == 3
|
| 183 |
+
assert DECORATOR_CALLED == [((1, 2), {})]
|
| 184 |
+
DECORATOR_CALLED.clear()
|
| 185 |
+
|
| 186 |
+
# Test with keyword arguments
|
| 187 |
+
assert DecoratedClass.add_staticmethod(a=3, b=4) == 7
|
| 188 |
+
assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})]
|
| 189 |
+
DECORATOR_CALLED.clear()
|
| 190 |
+
|
| 191 |
+
# Test via instance
|
| 192 |
+
obj = DecoratedClass(10)
|
| 193 |
+
assert obj.add_staticmethod(5, 6) == 11
|
| 194 |
+
assert DECORATOR_CALLED == [((5, 6), {})]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def test_staticmethod_async():
|
| 198 |
+
assert await DecoratedClass.add_staticmethod_async(1, 2) == 3
|
| 199 |
+
assert DECORATOR_CALLED == [((1, 2), {})]
|
| 200 |
+
DECORATOR_CALLED.clear()
|
| 201 |
+
|
| 202 |
+
# Test with keyword arguments
|
| 203 |
+
assert await DecoratedClass.add_staticmethod_async(a=3, b=4) == 7
|
| 204 |
+
assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})]
|
| 205 |
+
DECORATOR_CALLED.clear()
|
| 206 |
+
|
| 207 |
+
# Test via instance
|
| 208 |
+
obj = DecoratedClass(10)
|
| 209 |
+
assert await obj.add_staticmethod_async(5, 6) == 11
|
| 210 |
+
assert DECORATOR_CALLED == [((5, 6), {})]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_staticmethod_wrong_order():
|
| 214 |
+
assert DecoratedClass.add_staticmethod_reverse_decorator_order(1, 2) == 3
|
| 215 |
+
assert DECORATOR_CALLED == [((1, 2), {})]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
async def test_staticmethod_async_wrong_order():
|
| 219 |
+
assert (
|
| 220 |
+
await DecoratedClass.add_staticmethod_async_reverse_decorator_order(1, 2) == 3
|
| 221 |
+
)
|
| 222 |
+
assert DECORATOR_CALLED == [((1, 2), {})]
|