Skip to content

[DNM] Support functools.partial as tools, prompts, and resources#3870

Open
strawgate wants to merge 2 commits intomainfrom
fix/partial-support
Open

[DNM] Support functools.partial as tools, prompts, and resources#3870
strawgate wants to merge 2 commits intomainfrom
fix/partial-support

Conversation

@strawgate
Copy link
Copy Markdown
Collaborator

@strawgate strawgate commented Apr 12, 2026

Supersedes #3269. Closes #3266.

Bug fix

functools.partial objects failed in two ways:

  1. Registration: @mcp.tool(partial_fn) raised TypeError because inspect.isroutine() returns False for partials
  2. Execution: mcp.add_tool(partial_fn) with update_wrapper caused Pydantic to follow __wrapped__ back to the original signature, ignoring bound arguments — tools accepted the wrong parameters at call time

Both issues affected prompts and resources too, not just tools.

Consolidation (no behavior changes)

The fix required touching callable-handling code scattered across 11 files. Rather than adding 13 more isinstance(fn, functools.partial) checks (as #3269 did), this PR centralizes the patterns into focused utilities:

Utility Replaces Callsites
is_callable_object() inspect.isroutine() checks in decorator gates 7
get_callable_name() getattr(fn, "__name__") or fn.__class__.__name__ 4
prepare_callable() Duplicated unwrap blocks (partial/callable class/staticmethod) 5
set_fastmcp_meta() target = fn.__func__; target.__fastmcp__ = metadata 6
TaskConfig.normalize() Identical 6-line if/elif/else normalization blocks 4

Each utility does one thing and is directly tested.

import functools
from fastmcp import FastMCP

mcp = FastMCP("demo")

def add(x: int, y: int) -> int:
    return x + y

add_ten = functools.partial(add, y=10)
functools.update_wrapper(add_ten, add)

mcp.tool(add_ten)        # was TypeError
mcp.add_tool(add_ten)    # was ValidationError at call time

🤖 Generated with Claude Code

@strawgate strawgate added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. enhancement Improvement to existing functionality. For issues and smaller PR improvements. labels Apr 12, 2026
@marvin-context-protocol marvin-context-protocol bot added server Related to FastMCP server implementation or server-side functionality. and removed enhancement Improvement to existing functionality. For issues and smaller PR improvements. labels Apr 12, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The ty type checker failed because tests/tools/tool/test_partial.py imports from fastmcp.tools.tool, a module that doesn't exist.

Root Cause: The new test file imports from fastmcp.tools.tool import Tool (line 11), but the Tool class lives in fastmcp.tools.base, not fastmcp.tools.tool. There is no src/fastmcp/tools/tool.py module.

Suggested Solution: Fix the import in tests/tools/tool/test_partial.py:

# Change this:
from fastmcp.tools.tool import Tool

# To this:
from fastmcp.tools.base import Tool
Detailed Analysis

Failing check: ty check (static type analysis)

Log excerpt:

ty check.................................................................[Failed]
- hook id: ty
- exit code: 1

  error[unresolved-import]: Cannot resolve imported module `fastmcp.tools.tool`
    --> tests/tools/tool/test_partial.py:11:6
     |
10 | from fastmcp import FastMCP
11 | from fastmcp.tools.tool import Tool
     |      ^^^^^^^^^^^^^^^^^^
     |
  Found 1 diagnostic

Module structure (src/fastmcp/tools/):

  • __init__.py
  • base.pyTool is defined here (line 140)
  • function_tool.py
  • function_parsing.py
  • tool_transform.py

There is no tool.py in this directory. All other files in the PR that reference Tool correctly use from fastmcp.tools.base import Tool.

Related Files
  • tests/tools/tool/test_partial.py — the new test file with the incorrect import (line 11)
  • src/fastmcp/tools/base.py — where Tool is actually defined (line 140)

🤖 Triage by Marvin

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4478c68418

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# if the fn is a staticmethod, we need to work with the underlying function
if isinstance(fn, staticmethod):
fn = fn.__func__
fn = prepare_callable(fn)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve partial doc metadata when creating resources

Calling prepare_callable(fn) here reconstructs functools.partial objects that were wrapped with functools.update_wrapper, which drops copied attributes like __doc__; later in FunctionResource.from_function the default description is derived from inspect.getdoc(fn), so resources created from wrapped partials now get the generic functools.partial docstring instead of the original function doc. This is a user-visible regression for mcp.add_resource(partial_fn)/@resource(...) flows that rely on inferred descriptions.

Useful? React with 👍 / 👎.

@strawgate strawgate changed the title Support functools.partial as tools, prompts, and resources [DNM] Support functools.partial as tools, prompts, and resources Apr 12, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d26254a84d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# if the fn is a staticmethod, we need to work with the underlying function
if isinstance(fn, staticmethod):
fn = fn.__func__
fn = prepare_callable(fn)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prepare partials before validating resource-template params

Call-site validation for FunctionResourceTemplate.from_function runs on the original callable before this prepare_callable step, so functools.update_wrapper-wrapped partials are still validated against the unbound signature. In practice, a partial that binds a required parameter (for example partial(fn, fmt='json') when fmt has no default) is incorrectly rejected as missing URI params, even though the partial already supplies it. Running prepare_callable before the earlier signature/parameter checks avoids this false ValueError and makes wrapped partials behave consistently with the new partial support.

Useful? React with 👍 / 👎.

@strawgate strawgate force-pushed the fix/partial-support branch 2 times, most recently from e5a7d40 to d73e6b2 Compare April 12, 2026 22:47
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d73e6b2426

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +103 to 104
if is_callable_object(name_or_fn):
return register(name_or_fn, name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive app tool names for partial callables

Allowing functools.partial through _dispatch_decorator now routes app.tool(partial_fn) into _register, but that path still requires getattr(fn, "__name__", None), which is missing for partials unless users called functools.update_wrapper or passed name=. In practice, app.tool(functools.partial(add, y=10)) now fails with ValueError("Cannot determine tool name..."), while other tool entry points (Tool.from_function/mcp.add_tool) can infer names for the same callable. This leaves partial support inconsistent and broken for the FastMCPApp.tool API.

Useful? React with 👍 / 👎.

strawgate and others added 2 commits April 12, 2026 18:45
functools.partial objects failed at registration (@mcp.tool didn't
recognize them) and at call time (update_wrapper set __wrapped__
causing Pydantic to ignore bound arguments).

Introduces centralized utilities replacing scattered patterns across
11 files:

callable_utils.py:
- is_callable_object(): TypeGuard replacing inspect.isroutine() in
  7 decorator entry points — recognizes partials as callables
- get_callable_name(): Extracts useful names from any callable type,
  including partials without update_wrapper
- prepare_callable(): Strips __wrapped__, unwraps callable classes
  and staticmethod — replaces 4 duplicated blocks

decorators.py:
- set_fastmcp_meta(): Attaches __fastmcp__ metadata through __func__
  for bound methods — replaces 5 identical 2-line blocks

TaskConfig:
- normalize(): Converts bool|TaskConfig|None to TaskConfig — replaces
  4 identical 6-line if/elif/else blocks

No behavior changes beyond the bug fix: existing lambda rejection,
validation, and error handling remain per-module policy.

Closes #3266

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unit tests for callable_utils.py (is_callable_object, get_callable_name,
prepare_callable) covering functions, partials, callable classes,
staticmethod, and lambda edge cases.

Integration tests for partial support across tools, prompts, and
resources via both add_tool/add_prompt and decorator APIs.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@strawgate strawgate force-pushed the fix/partial-support branch from d73e6b2 to eb45781 Compare April 12, 2026 23:46
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb4578170f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Strip __wrapped__ from partials so Pydantic sees the partial's own
# signature with bound args removed, not the original function's signature.
if isinstance(fn, functools.partial) and hasattr(fn, "__wrapped__"):
fn = functools.partial(fn.func, *fn.args, **fn.keywords)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve annotation metadata when cloning wrapped partials

Rebuilding a functools.partial here drops attributes copied by functools.update_wrapper (notably annotation/module context), so partials that rely on deferred annotations (e.g. from __future__ import annotations with custom types like 'User') can no longer be introspected by downstream schema generation and fail with NameError during tool/prompt/resource registration. This is a regression for wrapped partials that previously carried enough metadata for type resolution; the clone should retain the copied metadata while removing only __wrapped__.

Useful? React with 👍 / 👎.

@jlowin jlowin added the DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. label Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

What does this error message require me to do, please? [not a bug]

2 participants