From 0ef51d985befbec8685775b17ca586f5bf12b264 Mon Sep 17 00:00:00 2001 From: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:18:48 +0000 Subject: [PATCH] Support functools.partial as tools, prompts, and resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bill Easton 🤖 Generated with Claude Code --- src/fastmcp/prompts/function_prompt.py | 13 +- src/fastmcp/resources/function_resource.py | 13 +- src/fastmcp/resources/template.py | 11 +- .../local_provider/decorators/prompts.py | 2 +- .../local_provider/decorators/resources.py | 3 +- .../local_provider/decorators/tools.py | 2 +- src/fastmcp/server/tasks/config.py | 7 +- src/fastmcp/tools/function_parsing.py | 11 +- src/fastmcp/tools/function_tool.py | 3 +- tests/tools/tool/test_partial.py | 130 ++++++++++++++++++ 10 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 tests/tools/tool/test_partial.py diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index a58700a01..d5d2ebef5 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -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: diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index bf6673552..c722381ff 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -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" diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index c2fb1b622..8b177af3c 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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): diff --git a/src/fastmcp/server/providers/local_provider/decorators/prompts.py b/src/fastmcp/server/providers/local_provider/decorators/prompts.py index d36c7d2f4..28c92987c 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/src/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -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): diff --git a/src/fastmcp/server/providers/local_provider/decorators/resources.py b/src/fastmcp/server/providers/local_provider/decorators/resources.py index 80a3e9a5c..5d0b6865d 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/src/fastmcp/server/providers/local_provider/decorators/resources.py @@ -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. " diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index 93209eb21..c3cf7c8df 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -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): diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py index 4956a7667..79afea50f 100644 --- a/src/fastmcp/server/tasks/config.py +++ b/src/fastmcp/server/tasks/config.py @@ -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__ diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py index d48f6dbe6..e90330e1e 100644 --- a/src/fastmcp/tools/function_parsing.py +++ b/src/fastmcp/tools/function_parsing.py @@ -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): diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 6c1a361f6..823098a8f 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -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: diff --git a/tests/tools/tool/test_partial.py b/tests/tools/tool/test_partial.py new file mode 100644 index 000000000..328cf5f1b --- /dev/null +++ b/tests/tools/tool/test_partial.py @@ -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")]