mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Support functools.partial as tools, prompts, and resources
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
🤖 Generated with Claude Code
This commit is contained in:
parent
83d6254757
commit
0ef51d985b
10 changed files with 184 additions and 11 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import warnings
|
||||
|
|
@ -160,8 +161,16 @@ class FunctionPrompt(Prompt):
|
|||
task_config = task_value
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# if the fn is a functools.partial, strip __wrapped__ (set by
|
||||
# update_wrapper) so that inspect.signature() and Pydantic see the
|
||||
# partial's own signature with bound args removed, not the original's
|
||||
if isinstance(fn, functools.partial) and hasattr(fn, "__wrapped__"):
|
||||
fn = functools.partial(fn.func, *fn.args, **fn.keywords)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
# functools.partial is not a routine but Pydantic handles it natively,
|
||||
# so we must not unwrap it to __call__ (which yields a method-wrapper)
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
|
|
@ -463,7 +472,7 @@ def prompt(
|
|||
return create_prompt(fn, prompt_name) # type: ignore[return-value]
|
||||
return attach_metadata(fn, prompt_name)
|
||||
|
||||
if inspect.isroutine(name_or_fn):
|
||||
if inspect.isroutine(name_or_fn) or isinstance(name_or_fn, functools.partial):
|
||||
return decorator(name_or_fn, name)
|
||||
elif isinstance(name_or_fn, str):
|
||||
if name is not None:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
|
|
@ -169,8 +170,16 @@ class FunctionResource(Resource):
|
|||
task_config = task_value
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# if the fn is a functools.partial, strip __wrapped__ (set by
|
||||
# update_wrapper) so that inspect.signature() and Pydantic see the
|
||||
# partial's own signature with bound args removed, not the original's
|
||||
if isinstance(fn, functools.partial) and hasattr(fn, "__wrapped__"):
|
||||
fn = functools.partial(fn.func, *fn.args, **fn.keywords)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
# functools.partial is not a routine but Pydantic handles it natively,
|
||||
# so we must not unwrap it to __call__ (which yields a method-wrapper)
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
|
|
@ -256,7 +265,7 @@ def resource(
|
|||
if isinstance(annotations, dict):
|
||||
annotations = Annotations(**annotations)
|
||||
|
||||
if inspect.isroutine(uri):
|
||||
if inspect.isroutine(uri) or isinstance(uri, functools.partial):
|
||||
raise TypeError(
|
||||
"The @resource decorator requires a URI. "
|
||||
"Use @resource('uri') instead of @resource"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
|
@ -551,8 +552,16 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
task_config = task
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# if the fn is a functools.partial, strip __wrapped__ (set by
|
||||
# update_wrapper) so that inspect.signature() and Pydantic see the
|
||||
# partial's own signature with bound args removed, not the original's
|
||||
if isinstance(fn, functools.partial) and hasattr(fn, "__wrapped__"):
|
||||
fn = functools.partial(fn.func, *fn.args, **fn.keywords)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
# functools.partial is not a routine but Pydantic handles it natively,
|
||||
# so we must not unwrap it to __call__ (which yields a method-wrapper)
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ class PromptDecoratorMixin:
|
|||
self.add_prompt(fn)
|
||||
return fn
|
||||
|
||||
if inspect.isroutine(name_or_fn):
|
||||
if inspect.isroutine(name_or_fn) or isinstance(name_or_fn, partial):
|
||||
return decorate_and_register(name_or_fn, name)
|
||||
|
||||
elif isinstance(name_or_fn, str):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ and template registration functionality to LocalProvider.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
|
@ -159,7 +160,7 @@ class ResourceDecoratorMixin:
|
|||
if isinstance(annotations, dict):
|
||||
annotations = Annotations(**annotations)
|
||||
|
||||
if inspect.isroutine(uri):
|
||||
if inspect.isroutine(uri) or isinstance(uri, functools.partial):
|
||||
raise TypeError(
|
||||
"The @resource decorator was used incorrectly. "
|
||||
"It requires a URI as the first argument. "
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ class ToolDecoratorMixin:
|
|||
tool_obj = self.add_tool(fn)
|
||||
return fn
|
||||
|
||||
if inspect.isroutine(name_or_fn):
|
||||
if inspect.isroutine(name_or_fn) or isinstance(name_or_fn, partial):
|
||||
return decorate_and_register(name_or_fn, name)
|
||||
|
||||
elif isinstance(name_or_fn, str):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ handle task-augmented execution as specified in SEP-1686.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -124,7 +125,11 @@ class TaskConfig:
|
|||
|
||||
# Unwrap callable classes and staticmethods
|
||||
fn_to_check = fn
|
||||
if not inspect.isroutine(fn) and callable(fn):
|
||||
if (
|
||||
not inspect.isroutine(fn)
|
||||
and not isinstance(fn, functools.partial)
|
||||
and callable(fn)
|
||||
):
|
||||
fn_to_check = fn.__call__
|
||||
if isinstance(fn_to_check, staticmethod):
|
||||
fn_to_check = fn_to_check.__func__
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -102,8 +103,16 @@ class ParsedFunction:
|
|||
fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__
|
||||
fn_doc = inspect.getdoc(fn)
|
||||
|
||||
# if the fn is a functools.partial, strip __wrapped__ (set by
|
||||
# update_wrapper) so that inspect.signature() and Pydantic see the
|
||||
# partial's own signature with bound args removed, not the original's
|
||||
if isinstance(fn, functools.partial) and hasattr(fn, "__wrapped__"):
|
||||
fn = functools.partial(fn.func, *fn.args, **fn.keywords)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn):
|
||||
# functools.partial is not a routine but Pydantic handles it natively,
|
||||
# so we must not unwrap it to __call__ (which yields a method-wrapper)
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
# if the fn is a staticmethod, we need to work with the underlying function
|
||||
if isinstance(fn, staticmethod):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
|
|
@ -452,7 +453,7 @@ def tool(
|
|||
return create_tool(fn, tool_name) # type: ignore[return-value]
|
||||
return attach_metadata(fn, tool_name)
|
||||
|
||||
if inspect.isroutine(name_or_fn):
|
||||
if inspect.isroutine(name_or_fn) or isinstance(name_or_fn, functools.partial):
|
||||
return decorator(name_or_fn, name)
|
||||
elif isinstance(name_or_fn, str):
|
||||
if name is not None:
|
||||
|
|
|
|||
130
tests/tools/tool/test_partial.py
Normal file
130
tests/tools/tool/test_partial.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Tests for functools.partial support as tools.
|
||||
|
||||
See https://github.com/PrefectHQ/fastmcp/issues/3266
|
||||
"""
|
||||
|
||||
import functools
|
||||
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
||||
class TestPartialTool:
|
||||
"""Test tools created from functools.partial objects."""
|
||||
|
||||
async def test_partial_sync(self):
|
||||
"""Test that a sync functools.partial works as a tool."""
|
||||
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
partial_add = functools.partial(add, y=10)
|
||||
functools.update_wrapper(partial_add, add)
|
||||
|
||||
tool = Tool.from_function(partial_add)
|
||||
result = await tool.run({"x": 5})
|
||||
assert result.content == [TextContent(type="text", text="15")]
|
||||
|
||||
async def test_partial_async(self):
|
||||
"""Test that an async functools.partial works as a tool."""
|
||||
|
||||
async def multiply(x: int, factor: int) -> int:
|
||||
return x * factor
|
||||
|
||||
partial_mul = functools.partial(multiply, factor=3)
|
||||
functools.update_wrapper(partial_mul, multiply)
|
||||
|
||||
tool = Tool.from_function(partial_mul)
|
||||
result = await tool.run({"x": 7})
|
||||
assert result.content == [TextContent(type="text", text="21")]
|
||||
|
||||
async def test_partial_preserves_name(self):
|
||||
"""Test that the tool name comes from the wrapped function."""
|
||||
|
||||
def greet(name: str, greeting: str = "Hello") -> str:
|
||||
"""Greet someone."""
|
||||
return f"{greeting}, {name}!"
|
||||
|
||||
partial_greet = functools.partial(greet, greeting="Hi")
|
||||
functools.update_wrapper(partial_greet, greet)
|
||||
|
||||
tool = Tool.from_function(partial_greet)
|
||||
assert tool.name == "greet"
|
||||
assert tool.description == "Greet someone."
|
||||
|
||||
async def test_partial_custom_name(self):
|
||||
"""Test that a custom name overrides the partial's wrapped name."""
|
||||
|
||||
def compute(x: int, op: str) -> str:
|
||||
return f"{op}({x})"
|
||||
|
||||
partial_fn = functools.partial(compute, op="square")
|
||||
functools.update_wrapper(partial_fn, compute)
|
||||
|
||||
tool = Tool.from_function(partial_fn, name="square")
|
||||
assert tool.name == "square"
|
||||
|
||||
async def test_partial_schema_shows_bound_args_as_optional(self):
|
||||
"""Test that bound arguments appear as optional with default values."""
|
||||
|
||||
def process(a: int, b: str, c: float = 1.0) -> str:
|
||||
return f"{a}-{b}-{c}"
|
||||
|
||||
partial_fn = functools.partial(process, b="fixed")
|
||||
functools.update_wrapper(partial_fn, process)
|
||||
|
||||
tool = Tool.from_function(partial_fn)
|
||||
props = tool.parameters.get("properties", {})
|
||||
required = tool.parameters.get("required", [])
|
||||
assert "a" in props
|
||||
assert "c" in props
|
||||
# b is bound by the partial so it appears as optional with its
|
||||
# bound value as the default
|
||||
assert "b" in props
|
||||
assert props["b"]["default"] == "fixed"
|
||||
assert "b" not in required
|
||||
|
||||
async def test_partial_without_update_wrapper(self):
|
||||
"""Test that functools.partial works without update_wrapper."""
|
||||
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
partial_add = functools.partial(add, y=10)
|
||||
# No update_wrapper call — name comes from the partial class
|
||||
|
||||
tool = Tool.from_function(partial_add, name="add_ten")
|
||||
result = await tool.run({"x": 5})
|
||||
assert result.content == [TextContent(type="text", text="15")]
|
||||
|
||||
async def test_partial_with_add_tool(self):
|
||||
"""Test registering a functools.partial via mcp.add_tool()."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
def greet(name: str, greeting: str = "Hello") -> str:
|
||||
return f"{greeting}, {name}!"
|
||||
|
||||
partial_greet = functools.partial(greet, greeting="Hey")
|
||||
functools.update_wrapper(partial_greet, greet)
|
||||
|
||||
mcp.add_tool(partial_greet)
|
||||
|
||||
result = await mcp.call_tool("greet", {"name": "World"})
|
||||
assert result.content == [TextContent(type="text", text="Hey, World!")]
|
||||
|
||||
async def test_partial_with_server_tool_decorator(self):
|
||||
"""Test registering a functools.partial via mcp.tool()."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
partial_add = functools.partial(add, y=100)
|
||||
functools.update_wrapper(partial_add, add)
|
||||
|
||||
mcp.tool(partial_add)
|
||||
|
||||
result = await mcp.call_tool("add", {"x": 5})
|
||||
assert result.content == [TextContent(type="text", text="105")]
|
||||
Loading…
Add table
Add a link
Reference in a new issue