mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-17 19:19:12 +02:00
Ensure methods work/are documented
This commit is contained in:
parent
d26a5dffc3
commit
f495de6f3a
6 changed files with 202 additions and 54 deletions
|
|
@ -16,11 +16,36 @@ When you apply a FastMCP decorator like `@tool`, `@resource()`, or `@prompt()` t
|
|||
|
||||
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.
|
||||
|
||||
Additionally, **FastMCP decorators return objects (Tool, Resource, or Prompt instances) rather than the original function**. This means that when you decorate a method directly, the method becomes the returned object and is no longer callable by your code:
|
||||
|
||||
<Warning>
|
||||
**Don't do this!**
|
||||
|
||||
The method will no longer be callable from Python, and the tool won't be callable by LLMs.
|
||||
|
||||
```python
|
||||
|
||||
from fastmcp import FastMCP
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool()
|
||||
def my_method(self, x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
obj = MyClass()
|
||||
obj.my_method(5) # Fails - my_method is a Tool, not a function
|
||||
```
|
||||
</Warning>
|
||||
|
||||
This is another important reason to register methods functionally after defining the class.
|
||||
|
||||
## Recommended Patterns
|
||||
|
||||
### Instance Methods
|
||||
|
||||
**Don't do this** (it doesn't work properly):
|
||||
<Warning>
|
||||
**Don't do this!**
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -28,17 +53,14 @@ from fastmcp import FastMCP
|
|||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool() # This won't work correctly
|
||||
@mcp.tool() # This won't work correctly
|
||||
def add(self, x, y):
|
||||
return x + y
|
||||
|
||||
@mcp.resource("resource://{param}") # This won't work correctly
|
||||
def get_resource(self, param: str):
|
||||
return f"Resource data for {param}"
|
||||
```
|
||||
|
||||
</Warning>
|
||||
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.
|
||||
|
||||
<Check>
|
||||
**Do this instead**:
|
||||
|
||||
```python
|
||||
|
|
@ -49,21 +71,15 @@ mcp = FastMCP()
|
|||
class MyClass:
|
||||
def add(self, x, y):
|
||||
return x + y
|
||||
|
||||
def get_resource(self, param: str):
|
||||
return f"Resource data for {param}"
|
||||
|
||||
# Create an instance first, then add the bound methods
|
||||
# Create an instance first, then register the bound methods
|
||||
obj = MyClass()
|
||||
mcp.add_tool(obj.add)
|
||||
mcp.add_resource_fn(obj.get_resource, uri="resource://{param}") # For resources or templates
|
||||
|
||||
# Note: FastMCP provides add_resource() for adding Resource objects directly and
|
||||
# add_resource_fn() for adding functions that generate resources or templates
|
||||
mcp.tool(obj.add)
|
||||
|
||||
# Now you can call it without 'self' showing up as a parameter
|
||||
await mcp.call_tool('add', {'x': 1, 'y': 2}) # Returns 3
|
||||
await mcp._mcp_call_tool('add', {'x': 1, 'y': 2}) # Returns 3
|
||||
```
|
||||
</Check>
|
||||
|
||||
This approach works because:
|
||||
1. You first create an instance of the class (`obj`)
|
||||
|
|
@ -72,9 +88,10 @@ This approach works because:
|
|||
|
||||
### Class Methods
|
||||
|
||||
Similar to instance methods, decorating class methods directly doesn't work properly:
|
||||
The behavior of decorating class methods depends on the order of decorators:
|
||||
|
||||
**Don't do this**:
|
||||
<Warning>
|
||||
**Don't do this** (decorator order matters):
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -83,13 +100,21 @@ mcp = FastMCP()
|
|||
|
||||
class MyClass:
|
||||
@classmethod
|
||||
@mcp.tool() # This won't work correctly
|
||||
def from_string(cls, s):
|
||||
@mcp.tool() # This won't work but won't raise an error
|
||||
def from_string_v1(cls, s):
|
||||
return cls(s)
|
||||
|
||||
@mcp.tool()
|
||||
@classmethod # This will raise a helpful ValueError
|
||||
def from_string_v2(cls, s):
|
||||
return cls(s)
|
||||
```
|
||||
</Warning>
|
||||
|
||||
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.
|
||||
- If `@classmethod` comes first, then `@mcp.tool()`: No error is raised, but it won't work correctly
|
||||
- If `@mcp.tool()` comes first, then `@classmethod`: FastMCP will detect this and raise a helpful `ValueError` with guidance
|
||||
|
||||
<Check>
|
||||
**Do this instead**:
|
||||
|
||||
```python
|
||||
|
|
@ -102,9 +127,10 @@ class MyClass:
|
|||
def from_string(cls, s):
|
||||
return cls(s)
|
||||
|
||||
# Add the class method after the class is defined
|
||||
mcp.add_tool(MyClass.from_string)
|
||||
# Register the class method after the class is defined
|
||||
mcp.tool(MyClass.from_string)
|
||||
```
|
||||
</Check>
|
||||
|
||||
This works because:
|
||||
1. The `@classmethod` decorator is applied properly during class definition
|
||||
|
|
@ -113,7 +139,10 @@ This works because:
|
|||
|
||||
### Static Methods
|
||||
|
||||
Unlike instance and class methods, static methods work fine with FastMCP decorators:
|
||||
Static methods "work" with FastMCP decorators, but this is not recommended because the FastMCP decorator will not return a callable method. Therefore, you should register static methods the same way as other methods.
|
||||
|
||||
<Warning>
|
||||
**This is not recommended, though it will work.**
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -121,23 +150,17 @@ from fastmcp import FastMCP
|
|||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool()
|
||||
@staticmethod
|
||||
@mcp.tool() # This works!
|
||||
def utility(x, y):
|
||||
return x + y
|
||||
|
||||
@staticmethod
|
||||
@mcp.resource("resource://data") # This works too!
|
||||
def get_data():
|
||||
return "Static resource data"
|
||||
```
|
||||
</Warning>
|
||||
|
||||
This approach works because:
|
||||
1. The `@staticmethod` decorator is applied first (executed last), transforming the method into a regular function
|
||||
2. When the FastMCP decorator is applied, it's capturing what is effectively just a regular function
|
||||
3. A static method doesn't have any binding requirements - it doesn't receive a `self` or `cls` parameter
|
||||
This works because `@staticmethod` converts the method to a regular function, which the FastMCP decorator can then properly process. However, this is not recommended because the FastMCP decorator will not return a callable staticmethod. Therefore, you should register static methods the same way as other methods.
|
||||
|
||||
Alternatively, you can use the same pattern as the other methods:
|
||||
<Check>
|
||||
**Prefer this pattern:**
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -150,10 +173,9 @@ class MyClass:
|
|||
return x + y
|
||||
|
||||
# This also works
|
||||
mcp.add_tool(MyClass.utility)
|
||||
mcp.tool(MyClass.utility)
|
||||
```
|
||||
|
||||
This works for the same reason - a static method is essentially just a function in a class namespace.
|
||||
</Check>
|
||||
|
||||
## Additional Patterns
|
||||
|
||||
|
|
@ -169,8 +191,8 @@ mcp = FastMCP()
|
|||
class ComponentProvider:
|
||||
def __init__(self, mcp_instance):
|
||||
# Register methods
|
||||
mcp_instance.add_tool(self.tool_method)
|
||||
mcp_instance.add_resource_fn(self.resource_method, uri="resource://data")
|
||||
mcp_instance.tool(self.tool_method)
|
||||
mcp_instance.resource("resource://data")(self.resource_method)
|
||||
|
||||
def tool_method(self, x):
|
||||
return x * 2
|
||||
|
|
@ -191,11 +213,13 @@ The class automatically registers its methods during initialization, ensuring th
|
|||
|
||||
## Summary
|
||||
|
||||
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.
|
||||
The current behavior of FastMCP decorators with methods is:
|
||||
|
||||
These patterns apply to all FastMCP decorators and registration methods:
|
||||
- `@tool()` and `add_tool`
|
||||
- `@resource()` and `add_resource_fn()`
|
||||
- `@prompt()` and `add_prompt()`
|
||||
- **Static methods**: Can be decorated directly and work perfectly with all FastMCP decorators
|
||||
- **Class methods**: Cannot be decorated directly and will raise a helpful `ValueError` with guidance
|
||||
- **Instance methods**: Should be registered after creating an instance using the decorator calls
|
||||
|
||||
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.
|
||||
For class and instance methods, you should register them after creating the instance or class to ensure proper method binding. This ensures that the methods are properly bound before being registered.
|
||||
|
||||
|
||||
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.
|
||||
|
|
@ -171,6 +171,9 @@ class FunctionPrompt(Prompt):
|
|||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
fn = fn.__func__
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
parameters = type_adapter.json_schema()
|
||||
|
|
|
|||
|
|
@ -225,8 +225,12 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
|
||||
description = description or fn.__doc__
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
fn = fn.__func__
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
parameters = type_adapter.json_schema()
|
||||
|
|
|
|||
|
|
@ -589,8 +589,20 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if isinstance(annotations, dict):
|
||||
annotations = ToolAnnotations(**annotations)
|
||||
|
||||
if isinstance(name_or_fn, classmethod):
|
||||
raise ValueError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
tool() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Determine the actual name and function based on the calling pattern
|
||||
if callable(name_or_fn):
|
||||
if inspect.isroutine(name_or_fn):
|
||||
# Case 1: @tool (without parens) - function passed directly
|
||||
# Case 2: direct call like tool(fn, name="something")
|
||||
fn = name_or_fn
|
||||
|
|
@ -747,7 +759,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
return f"Weather for {city}: {data}"
|
||||
"""
|
||||
# Check if user passed function directly instead of calling decorator
|
||||
if callable(uri):
|
||||
if inspect.isroutine(uri):
|
||||
raise TypeError(
|
||||
"The @resource decorator was used incorrectly. "
|
||||
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
||||
|
|
@ -756,6 +768,18 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
def decorator(fn: AnyFunction) -> Resource | ResourceTemplate:
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
if isinstance(fn, classmethod):
|
||||
raise ValueError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
resource() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Check if this should be a template
|
||||
has_uri_params = "{" in uri and "}" in uri
|
||||
# check if the function has any parameters (other than injected context)
|
||||
|
|
@ -896,8 +920,21 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# Direct function call
|
||||
server.prompt(my_function, name="custom_name")
|
||||
"""
|
||||
|
||||
if isinstance(name_or_fn, classmethod):
|
||||
raise ValueError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
prompt() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Determine the actual name and function based on the calling pattern
|
||||
if callable(name_or_fn):
|
||||
if inspect.isroutine(name_or_fn):
|
||||
# Case 1: @prompt (without parens) - function passed directly as decorator
|
||||
# Case 2: direct call like prompt(fn, name="something")
|
||||
fn = name_or_fn
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ class FunctionTool(Tool):
|
|||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
fn = fn.__func__
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
schema = type_adapter.json_schema()
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ class TestToolDecorator:
|
|||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@staticmethod
|
||||
@mcp.tool
|
||||
@staticmethod
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
|
|
@ -223,6 +223,17 @@ class TestToolDecorator:
|
|||
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
||||
assert result[0].text == "3" # type: ignore[attr-defined]
|
||||
|
||||
async def test_tool_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="To decorate a classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool
|
||||
@classmethod
|
||||
def add(cls, y: int) -> None:
|
||||
pass
|
||||
|
||||
async def test_tool_decorator_classmethod_async_function(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
|
|
@ -249,6 +260,20 @@ class TestToolDecorator:
|
|||
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
||||
assert result[0].text == "3" # type: ignore[attr-defined]
|
||||
|
||||
async def test_tool_decorator_staticmethod_order(self):
|
||||
"""Test that the recommended decorator order works for static methods"""
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@staticmethod
|
||||
@mcp.tool
|
||||
def add_v1(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
# Test that the recommended order works
|
||||
result = await mcp._mcp_call_tool("add_v1", {"x": 1, "y": 2})
|
||||
assert result[0].text == "3" # type: ignore[attr-defined]
|
||||
|
||||
async def test_tool_decorator_with_tags(self):
|
||||
"""Test that the tool decorator properly sets tags."""
|
||||
mcp = FastMCP()
|
||||
|
|
@ -480,12 +505,23 @@ class TestResourceDecorator:
|
|||
result = await client.read_resource("resource://data")
|
||||
assert result[0].text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
|
||||
|
||||
async def test_resource_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="To decorate a classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.resource("resource://data")
|
||||
@classmethod
|
||||
def get_data(cls) -> None:
|
||||
pass
|
||||
|
||||
async def test_resource_decorator_staticmethod(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@staticmethod
|
||||
@mcp.resource("resource://data")
|
||||
@staticmethod
|
||||
def get_data() -> str:
|
||||
return "Static Hello, world!"
|
||||
|
||||
|
|
@ -504,6 +540,20 @@ class TestResourceDecorator:
|
|||
result = await client.read_resource("resource://data")
|
||||
assert result[0].text == "Async Hello, world!" # type: ignore[attr-defined]
|
||||
|
||||
async def test_resource_decorator_staticmethod_order(self):
|
||||
"""Test that both decorator orders work for static methods"""
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.resource("resource://data") # type: ignore[misc] # Type checker warns but runtime works
|
||||
@staticmethod
|
||||
def get_data() -> str:
|
||||
return "Static Hello, world!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://data")
|
||||
assert result[0].text == "Static Hello, world!" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestTemplateDecorator:
|
||||
async def test_template_decorator(self):
|
||||
|
|
@ -609,8 +659,8 @@ class TestTemplateDecorator:
|
|||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@staticmethod
|
||||
@mcp.resource("resource://{name}/data")
|
||||
@staticmethod
|
||||
def get_data(name: str) -> str:
|
||||
return f"Static Data for {name}"
|
||||
|
||||
|
|
@ -783,12 +833,23 @@ class TestPromptDecorator:
|
|||
message = result.messages[0]
|
||||
assert message.content.text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
|
||||
|
||||
async def test_prompt_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="To decorate a classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.prompt
|
||||
@classmethod
|
||||
def test_prompt(cls) -> None:
|
||||
pass
|
||||
|
||||
async def test_prompt_decorator_staticmethod(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@staticmethod
|
||||
@mcp.prompt
|
||||
@staticmethod
|
||||
def test_prompt() -> str:
|
||||
return "Static Hello, world!"
|
||||
|
||||
|
|
@ -881,6 +942,22 @@ class TestPromptDecorator:
|
|||
def my_function() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
async def test_prompt_decorator_staticmethod_order(self):
|
||||
"""Test that both decorator orders work for static methods"""
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyClass:
|
||||
@mcp.prompt # type: ignore[misc] # Type checker warns but runtime works
|
||||
@staticmethod
|
||||
def test_prompt() -> str:
|
||||
return "Static Hello, world!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("test_prompt")
|
||||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
assert message.content.text == "Static Hello, world!" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestResourcePrefixHelpers:
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue