fastmcp icon indicating copy to clipboard operation
fastmcp copied to clipboard

Explore Tool Decorator Changes

Open strawgate opened this issue 2 months ago • 7 comments

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.

strawgate avatar Oct 28 '25 14:10 strawgate

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:

  1. Return objects, not callables: When @mcp.tool is applied, it returns a Tool instance, not the original function. This is by design - decorators register components with the MCP server.

  2. Cannot handle unbound methods: When applied to instance methods at class definition time, the decorator receives an unbound function where self is just another parameter. The MCP system would expose self to LLMs, which cannot provide it.

  3. Explicitly detect and reject @classmethod decorator stacking: The code at src/fastmcp/server/server.py:1463-1473 raises a helpful ValueError when it detects @classmethod is 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:

  1. Decorators must return non-callable objects: FastMCP decorators return Tool/Resource/Prompt objects 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.

  2. Registration happens at decoration time: The entire point of @mcp.tool is to register the tool immediately with the server's ToolManager. 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?)
  3. 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 method
    

    With decorator application, this becomes impossible because the decorator replaces the method with a Tool object.

  4. Multiple instance problem: If @mcp.tool worked 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 classmethod and 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:

  1. Keep existing documentation: The Decorating Methods guide already clearly explains the correct patterns and includes warnings about incorrect usage.

  2. Maintain current error detection: The isinstance(name_or_fn, classmethod) check in server.py:1463 provides helpful error messages.

  3. 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
  4. Future exploration (if demand is high):

    • Create a contrib module fastmcp.contrib.class_tools that 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

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.

jlowin avatar Oct 28 '25 17:10 jlowin

@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()

strawgate avatar Oct 28 '25 18:10 strawgate

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 MyClass because it needs to be accessed (literally calling MyClass.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 been Tool-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. 🧜‍♀️

leycec avatar Oct 29 '25 06:10 leycec

/marvin please apologize

strawgate avatar Oct 29 '25 14:10 strawgate

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

strawgate avatar Oct 29 '25 14:10 strawgate