Explore Tool Decorator Changes
Enhancement
Let's explore this https://github.com/jlowin/fastmcp/issues/2257
Totally unrelated to https://github.com/beartype and type-checking, but FastMCP's "Decorating Methods" caveats doesn't seem quite right. On the face of it, it really does seem like a future version of FastMCP should be able to transparently support standard-issue decoration use cases like: from fastmcp import FastMCP mcp = FastMCP()
class MyClass: @mcp.tool # <-- Seems like FastMCP really should be able to make that work? Maybe? def my_method(self, x: int) -> int: return x * 2 In theory, the @FastMCP.tool decorator should be able to support both instance and class methods by (in order):
Detecting when it's being applied to an instance or class method. @FastMCP.tool currently detects only the latter, but could detect the former as well by either: Trivial signature introspection. If the first parameter is self, it's almost certainly an instance method. Non-trivial call stack introspection. Technically, classes are lexical scopes. This means that @FastMCP.tool can iteratively crawl up the call stack until it either leaves a module boundary or finds a class scope. Definitely hard stuff. Definitely doable, too. Personally, I'd just wing it and introspect for self parameters until users complain. 😆 If @FastMCP.tool detects it's being applied to an instance or class method, it returns a non-data descriptor (i.e., an object whose class satisfies the descriptor protocol by defining the get() dunder method) rather than doing what it currently does. That descriptor's get() dunder method then (in order): Binds the function it was instantiated with to either the desired instance or class object. DOES ALL OF THE STUFF THE @FastMCP.tool DECORATOR CURRENTLY DOES. Clearly, this is where the saucy magic sauce happens. 😋 Returns the now-bound instance or class method. In theory, everything should just "work" from the end user perspective. You've almost certainly thought of all that, because you are FastMCP. There's a good reason why that simply doesn't work, I'm sure. Still. Descriptors are fun! I'm saying "fun" a lot. It's almost like that word means something different when I use it.
Claude finished @strawgate's task —— View job
Investigation Complete ✓
Analyzing descriptor-based decorator improvements for instance and class methods.
Recommendation
This enhancement should NOT be implemented. The current documented pattern of registering bound methods after class/instance creation is the correct approach for FastMCP, and attempting to make decorator-time binding work would introduce significant complexity and potential issues without meaningful user benefit.
Findings
Current Behavior
FastMCP decorators (@mcp.tool, @mcp.resource, @mcp.prompt) currently:
-
Return objects, not callables: When
@mcp.toolis applied, it returns aToolinstance, not the original function. This is by design - decorators register components with the MCP server. -
Cannot handle unbound methods: When applied to instance methods at class definition time, the decorator receives an unbound function where
selfis just another parameter. The MCP system would exposeselfto LLMs, which cannot provide it. -
Explicitly detect and reject
@classmethoddecorator stacking: The code atsrc/fastmcp/server/server.py:1463-1473raises a helpfulValueErrorwhen it detects@classmethodis stacked with@mcp.tool.
Documented Best Practice
The Decorating Methods documentation clearly explains the recommended pattern:
Instance methods:
class MyClass:
def add(self, x, y):
return x + y
obj = MyClass()
mcp.tool(obj.add) # Register bound method
Class methods:
class MyClass:
@classmethod
def from_string(cls, s):
return cls(s)
mcp.tool(MyClass.from_string) # Register after class definition
This pattern works because Python's method binding happens before registration, so self/cls is already bound and not exposed to the MCP schema.
Why the Descriptor Approach Won't Work
The suggestion from issue #2257 proposes using descriptors to defer binding, but this fundamentally conflicts with FastMCP's architecture:
-
Decorators must return non-callable objects: FastMCP decorators return
Tool/Resource/Promptobjects that get stored in manager registries. A descriptor's__get__method cannot be triggered if the decorator doesn't return something that gets stored as a class attribute. -
Registration happens at decoration time: The entire point of
@mcp.toolis to register the tool immediately with the server'sToolManager. Deferring this to attribute access time would require:- Storing descriptor objects in the class namespace
- Tracking which instances have been created
- Registering tools multiple times (once per instance)
- Managing tool lifecycle (what happens when instance is deleted?)
-
Breaking existing patterns: Users currently can and do call methods after registration:
obj = MyClass() mcp.tool(obj.add) result = obj.add(1, 2) # Still works because mcp.tool returns Tool, doesn't modify methodWith decorator application, this becomes impossible because the decorator replaces the method with a Tool object.
-
Multiple instance problem: If
@mcp.toolworked on instance methods, which instance would be used? Creating a tool per instance defeats the purpose of having a single MCP server with stable tool definitions.
Technical Implementation Details
Looking at src/fastmcp/server/server.py:1397-1529, the tool() method:
- Checks if passed a
classmethodand raises an error (line 1463) - Immediately calls
Tool.from_function(fn, ...)to create a Tool object - Calls
self.add_tool(tool)to register with ToolManager - Returns the Tool object (not the function)
The ParsedFunction.from_function() at src/fastmcp/tools/tool.py:368-399 uses inspect.signature() to extract parameters. It would see self as a regular parameter on unbound methods, and there's no safe way to detect and remove it without breaking legitimate use cases.
Comparison to Other Frameworks
FastMCP's approach aligns with how frameworks like Flask and FastAPI handle similar situations:
- Flask:
@app.route()decorators must be module-level, not inside classes (without workarounds) - FastAPI: Encourages functional endpoints or dependency injection rather than method-based endpoints
- MCP SDK: The underlying Python MCP SDK has no special support for method binding
Frameworks that do support method decoration (like Django CBVs) have fundamentally different architectures - they instantiate controller objects per request, whereas MCP servers define tools once at startup.
Detailed Action Plan
No implementation recommended. Instead:
-
Keep existing documentation: The Decorating Methods guide already clearly explains the correct patterns and includes warnings about incorrect usage.
-
Maintain current error detection: The
isinstance(name_or_fn, classmethod)check inserver.py:1463provides helpful error messages. -
Consider documentation improvements (optional):
- Add a FAQ section explaining why this pattern is necessary
- Include more examples of the ComponentProvider pattern (already documented)
- Link to this investigation for detailed technical rationale
-
Future exploration (if demand is high):
- Create a contrib module
fastmcp.contrib.class_toolsthat provides explicit class-based patterns like:class ToolProvider: @mcp_instance_tool # Hypothetical helper def my_method(self, x: int) -> int: return x * 2 mcp.register_provider(ToolProvider()) # Explicit registration step - This would make the registration step explicit while still supporting OOP patterns
- Create a contrib module
Related Items
Related Issues and Pull Requests
| Repository | Issue/PR | Relevance |
|---|---|---|
| jlowin/fastmcp | Issue #2257 | Original discussion about descriptor-based decorators (closed - was actually about Poetry/beartype issue) |
| jlowin/fastmcp | Issue #2292 | This investigation issue |
Related Files
| Repository | File | Relevance | Key Sections |
|---|---|---|---|
| jlowin/fastmcp | docs/patterns/decorating-methods.mdx | Existing documentation explaining current best practices | Full file |
| jlowin/fastmcp | src/fastmcp/server/server.py | Server decorator implementation | Lines 1397-1529 (tool decorator), 1463-1473 (classmethod detection) |
| jlowin/fastmcp | src/fastmcp/tools/tool.py | Tool creation and schema generation | Lines 244-306 (FunctionTool.from_function), 360-399 (ParsedFunction.from_function) |
| jlowin/fastmcp | src/fastmcp/tools/tool_manager.py | Tool registration and storage | Tool manager implementation |
Related Webpages
| Name | URL | Relevance |
|---|---|---|
| FastMCP Decorating Methods | https://gofastmcp.com/patterns/decorating-methods | Official documentation explaining correct method decoration patterns and why direct decoration doesn't work |
| Python Descriptor Protocol | https://docs.python.org/3/howto/descriptor.html | Python documentation on descriptors - useful for understanding why the proposed approach has fundamental limitations |
I think part of the issue here IIRC is that when you decorate an instance method, you need to access the method attribute on a hydrated parent instance in order to trigger the descriptor logic, which is th eonly opportunity to bind self to the parent instance. So for example
class MyClass():
@mcp.tool
def my_tool(self):
...
MyClass()
mcp.run()
In the above, my_tool has no binding or relationship to MyClass because it needs to be accessed (literally calling MyClass.my_tool) in order to trigger the descriptor logic.
So there's a few pitfalls here. One option is to make MyClass a subclass of a class that reviews its own attributes to see if they're deorated? I dont recall the exact process, I may be off here.
@leycec that's what I see too -- we have to access the method to trigger registration
I think the other part of the challenge with instance methods is that instantiating more than one instance gives us a conflict problem where tools have the same name.
--- PHASE 1: Class Definition ---
⚡ Detected instance method: multiply
→ Returning ToolDescriptor for deferred registration
⚡ Detected instance method: add
→ Returning ToolDescriptor for deferred registration
--- PHASE 2: Creating Instances ---
Creating calc1 (multiplier=2)...
Creating calc2 (multiplier=10)...
--- PHASE 3: Accessing Methods (Triggers Tool Creation) ---
Accessing calc1.multiply...
[DemoMCP] Registered tool: multiply
✓ Created and registered tool for instance 4307083264: Tool(name='multiply', params=['x'])
Got: Tool(name='multiply', params=['x'])
Accessing calc2.multiply...
[DemoMCP] Registered tool: multiply
✓ Created and registered tool for instance 4307087536: Tool(name='multiply', params=['x'])
Got: Tool(name='multiply', params=['x'])
"""
Proof of Concept: Using Descriptors to Support Instance Method Decoration
This POC explores how FastMCP could use Python's descriptor protocol to
transparently support decorating instance methods like:
class MyClass:
@mcp.tool
def my_method(self, x: int) -> int:
return self.multiplier * x
The key insight is that descriptors allow us to intercept attribute access,
so we can bind the method to the instance BEFORE creating the Tool.
"""
import inspect
from typing import Any, Callable
class SimplifiedTool:
"""A simplified Tool class for demonstration purposes."""
def __init__(self, fn: Callable[..., Any], name: str, description: str | None = None):
self.fn = fn
self.name = name
self.description = description or f"Tool: {name}"
# In real FastMCP, this would include JSON schema generation, etc.
self.parameters = self._extract_parameters(fn)
def _extract_parameters(self, fn: Callable[..., Any]) -> list[str]:
"""Extract parameter names (simplified version)."""
sig = inspect.signature(fn)
return [
param_name
for param_name, param in sig.parameters.items()
if param_name not in ('self', 'cls')
]
def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""Allow the tool to be called."""
return self.fn(*args, **kwargs)
def __repr__(self) -> str:
return f"Tool(name={self.name!r}, params={self.parameters})"
class ToolDescriptor:
"""
A descriptor that defers Tool creation until the method is accessed on an instance.
This is the key innovation - instead of creating the Tool at decoration time,
we create a descriptor that will create the Tool when the method is first accessed
through an instance.
"""
def __init__(
self,
fn: Callable[..., Any],
tool_factory: Callable[[Callable[..., Any]], SimplifiedTool],
registry: "ToolRegistry",
):
self.fn = fn
self.tool_factory = tool_factory
self.registry = registry
# Store tools created for each instance
self._instance_tools: dict[int, SimplifiedTool] = {}
def __get__(self, obj: Any, objtype: type | None = None) -> "SimplifiedTool | ToolDescriptor":
"""
The magic happens here! This is called when accessing the method.
- If accessed on the class (obj is None), return the descriptor itself
- If accessed on an instance, bind the method and create/return a Tool
"""
if obj is None:
# Accessing on the class, not an instance
return self
# Get or create a tool for this specific instance
instance_id = id(obj)
if instance_id not in self._instance_tools:
# Bind the function to the instance
bound_method = self.fn.__get__(obj, objtype)
# Create a tool from the bound method
tool = self.tool_factory(bound_method)
# Register it
self.registry.register_tool(tool)
# Cache it for this instance
self._instance_tools[instance_id] = tool
print(f"✓ Created and registered tool for instance {instance_id}: {tool}")
return self._instance_tools[instance_id]
def __set_name__(self, owner: type, name: str) -> None:
"""Called when the descriptor is assigned to a class attribute."""
self.name = name
class ToolRegistry:
"""Simplified registry to track registered tools."""
def __init__(self, name: str = "Registry"):
self.name = name
self.tools: dict[str, SimplifiedTool] = {}
def register_tool(self, tool: SimplifiedTool) -> None:
"""Register a tool."""
self.tools[tool.name] = tool
print(f" [{self.name}] Registered tool: {tool.name}")
def list_tools(self) -> list[str]:
"""List all registered tool names."""
return list(self.tools.keys())
def get_tool(self, name: str) -> SimplifiedTool | None:
"""Get a tool by name."""
return self.tools.get(name)
class SimplifiedFastMCP:
"""A simplified FastMCP class to demonstrate the descriptor approach."""
def __init__(self, name: str = "TestMCP"):
self.name = name
self.registry = ToolRegistry(name)
def _is_instance_method(self, fn: Callable[..., Any]) -> bool:
"""
Detect if a function is likely an instance method.
Strategy 1 (Simple): Check if first parameter is 'self'
Strategy 2 (Complex): Use call stack introspection (not shown here)
"""
sig = inspect.signature(fn)
params = list(sig.parameters.keys())
# Simple heuristic: first parameter is named 'self'
if params and params[0] == 'self':
return True
return False
def tool(
self,
fn: Callable[..., Any] | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> Callable[[Callable[..., Any]], Any] | SimplifiedTool | ToolDescriptor:
"""
Decorator to register a tool.
This enhanced version can handle instance methods by returning a descriptor.
"""
def decorator(func: Callable[..., Any]) -> SimplifiedTool | ToolDescriptor:
# Check if this is an instance method
if self._is_instance_method(func):
print(f"⚡ Detected instance method: {func.__name__}")
print(f" → Returning ToolDescriptor for deferred registration")
# Return a descriptor that will create the tool when accessed
def tool_factory(bound_method: Callable[..., Any]) -> SimplifiedTool:
tool_name = name or bound_method.__name__
return SimplifiedTool(
fn=bound_method,
name=tool_name,
description=description,
)
return ToolDescriptor(
fn=func,
tool_factory=tool_factory,
registry=self.registry,
)
else:
# Regular function - create tool immediately
print(f"⚡ Detected regular function: {func.__name__}")
tool_name = name or func.__name__
tool = SimplifiedTool(fn=func, name=tool_name, description=description)
self.registry.register_tool(tool)
print(f" → Created tool immediately: {tool}")
return tool
# Support both @tool and @tool() syntax
if fn is None:
return decorator
else:
return decorator(fn)
# =============================================================================
# DEMONSTRATION
# =============================================================================
def main():
"""Run the POC demonstration."""
print("="*70)
print("DESCRIPTOR-BASED TOOL DECORATOR POC")
print("="*70)
print()
# Create an MCP instance
mcp = SimplifiedFastMCP("DemoMCP")
print("--- PHASE 1: Class Definition ---")
print()
# Define a class with decorated instance methods
class Calculator:
"""A calculator with instance-specific multiplier."""
def __init__(self, multiplier: int):
self.multiplier = multiplier
@mcp.tool
def multiply(self, x: int) -> int:
"""Multiply a number by this calculator's multiplier."""
return self.multiplier * x
@mcp.tool(name="custom_add")
def add(self, a: int, b: int) -> int:
"""Add two numbers (with debug info)."""
result = a + b
print(f" [Calculator-{self.multiplier}] {a} + {b} = {result}")
return result
print()
print("--- PHASE 2: Creating Instances ---")
print()
# Create instances
print("Creating calc1 (multiplier=2)...")
calc1 = Calculator(multiplier=2)
print()
print("Creating calc2 (multiplier=10)...")
calc2 = Calculator(multiplier=10)
print()
print("--- PHASE 3: Accessing Methods (Triggers Tool Creation) ---")
print()
# Access the methods - this triggers the descriptor's __get__
print("Accessing calc1.multiply...")
tool1 = calc1.multiply
print(f" Got: {tool1}")
print()
print("Accessing calc2.multiply...")
tool2 = calc2.multiply
print(f" Got: {tool2}")
print()
print("--- PHASE 4: Using the Tools ---")
print()
# Now we can call the tools
print("Calling calc1.multiply (should multiply by 2)...")
result1 = calc1.multiply(5)
print(f" calc1.multiply(5) = {result1}")
assert result1 == 10, f"Expected 10, got {result1}"
print()
print("Calling calc2.multiply (should multiply by 10)...")
result2 = calc2.multiply(5)
print(f" calc2.multiply(5) = {result2}")
assert result2 == 50, f"Expected 50, got {result2}"
print()
print("Calling calc1.add...")
result3 = calc1.add(3, 4)
print(f" calc1.add(3, 4) = {result3}")
assert result3 == 7, f"Expected 7, got {result3}"
print()
print("--- PHASE 5: Comparing with Regular Functions ---")
print()
# Also test with a regular function for comparison
@mcp.tool
def regular_function(x: int) -> int:
"""A regular function, not a method."""
return x * 2
print()
print("Calling regular_function(5)...")
result4 = regular_function(5)
print(f" regular_function(5) = {result4}")
assert result4 == 10, f"Expected 10, got {result4}"
print()
print("--- PHASE 6: Registry Inspection ---")
print()
print("Tools in registry:")
for tool_name in mcp.registry.list_tools():
tool = mcp.registry.get_tool(tool_name)
print(f" • {tool_name}: {tool}")
print()
print("="*70)
print("POC COMPLETE ✓")
print("="*70)
print()
print("KEY INSIGHTS:")
print(" 1. Descriptors allow us to defer tool creation until method access")
print(" 2. Each instance gets its own bound method → its own tool")
print(" 3. The method remains callable from Python code")
print(" 4. Regular functions continue to work as before")
print(" 5. The 'self' parameter is hidden from the tool's parameter list")
if __name__ == "__main__":
main()
So inspiring! I... I can't believe the turn-around on FastMCP issues. The @beartype issue tracker is littered with the detritus of ancient cruft that everyone's long since given up on. I'm suddenly inspired to be more like everybody here.
My wife and I adore Claude 4.5 Sonnet (both Code and Chat). 127 IQ is no laughing matter. I stopped laughing when Claude politely suggested I was dumb. Claude was right. In this case, though, Claude seems... not right. I mean, sure. Technically, there is non-trivial magical dunder work needed to smooth the descriptor approach out. But when isn't there? This is Python. It's turtles and non-trivial magical dunder work all the way down the line.
The user workarounds needed to circumvent FastMCP's method decorator caveats are more surprising than any number of magical dunders, I'd say. User experience is primary. Everything else is secondary. Anything that can reduce the cognitive load and maintenance burden of using FastMCP can only be a good thing.
Of course, I'm also a bald middle-aged man at a lakeside cabin in the Canadian wilderness. It's best not to believe people like me.
In the above, my_tool has no binding or relationship to
MyClassbecause it needs to be accessed (literally callingMyClass.my_tool) in order to trigger the descriptor logic.
I... uhh. Conveniently neglected to mention that part. Okay. I forgot.
There are tons of obscure (meta)class dunder methods (like the little-known __prepare__()) that could be of assistance here. But I'd avoid metaclasses. There's nothing users hate more than metaclass conflicts. Everyone would probably be fine with subclassing a FastMCP-specific fastmcp.Toolable abstract base class (ABC), though. I would be. I'm someone, surely! 😅
Let the ABC take care of the nitty-gritty minutiae and most users would be peachy-keen.
I think the other part of the challenge with instance methods is that instantiating more than one instance gives us a conflict problem where tools have the same name.
I never thought that far ahead. Everyone here is already five steps into the future.
But doesn't the current decorator method workaround have the same exact issue? Maybe nobody ever tried to do this before:
from fastmcp import FastMCP
mcp = FastMCP()
class MyClass:
def add(self, x, y):
return x + y
foo = MyClass()
bar = MyClass()
mcp.tool(foo.add)
mcp.tool(bar.add) # <-- probably not a good thing
How does FastMCP handle that conflict at the moment? Just die and hope for the best, I suspect. That's fine under the existing workflow, but when you start dundering around with magical ABCs, fastmcp.Toolable should probably instead just:
- Detect whether an
@mcp.tool-decorated method has already beenTool-ized by a prior instance. - If so, just... what? Oh, right. This could be yet another option in the FastMCP configuration. A few obvious options arise:
- Default to raising an exception. Safety first. 🦺
- Allow users to silently ignore all but the first instantiation. The first object instantiated wins. Every other object's tools are ignored.
- Allow users to silently ignore all but the last instantiation. The last object instantiated wins. Every other object's tools are ignored.
Thanks so much for humouring me, everyone. This ~~pit of doom~~ dunder magic is splendid. Fun is intensifying. I can feel it in my aching arthritic bones. Best of luck with all this fun! I now retreat to the sidelines, where @beartype is calling her siren song. 🧜♀️
/marvin please apologize
Claude finished @strawgate's task —— View job
Apology
I apologize for my previous analysis being overly dismissive of this enhancement. @leycec makes excellent points about prioritizing user experience over implementation complexity, and the descriptor-based POC demonstrates that this is indeed feasible.
The current workarounds documented in our "Decorating Methods" guide do place unnecessary cognitive load on users. If descriptors and a Toolable ABC can make the intuitive @mcp.tool decoration "just work" for instance methods, that's worth the internal complexity.
You're right that the multi-instance conflict issue exists with the current workaround too - we should handle it consistently regardless of approach. The configuration options you suggested (raise exception by default, with options to use first/last instance) seem like a solid way forward.
Thank you for the thoughtful discussion and the detailed POC. This deserves proper exploration rather than a quick "don't do it" recommendation.
We do have an McpMixin class that provides similar functionality while requiring a registration step https://github.com/jlowin/fastmcp/tree/main/src/fastmcp/contrib/mcp_mixin
It's a bit less attractive and certainly not as well advertised