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:
Marvin Context Protocol 2026-02-21 21:18:48 +00:00
commit 0ef51d985b
10 changed files with 184 additions and 11 deletions

View file

@ -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:

View file

@ -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"

View file

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

View file

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

View file

@ -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. "

View file

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

View file

@ -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__

View file

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

View file

@ -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:

View 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")]