Skip to content

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

Open
strawgate wants to merge 3 commits into
mainfrom
fix/partial-support
Open

[DNM] Support functools.partial as tools, prompts, and resources#3870
strawgate wants to merge 3 commits into
mainfrom
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

marvin-context-protocol Bot commented Apr 12, 2026

(Edited to reflect the latest CI failure — supersedes prior analysis.)

tl;dr: ruff check flags one SIM105 violation in fastmcp_slim/fastmcp/utilities/callable_utils.py:80. Replace the inner try/except AttributeError: pass around setattr with contextlib.suppress(AttributeError) and push.

Root Cause: The new partial-unwrapping block copies wrapper attributes onto the rewritten functools.partial. The inner setattr is wrapped in try: ... except AttributeError: pass, which ruff's SIM105 rule disallows in favor of contextlib.suppress. Only one error — ruff format, ty, loq, and codespell all passed.

Fix: In fastmcp_slim/fastmcp/utilities/callable_utils.py:

import contextlib
# ...
for attr in functools.WRAPPER_ASSIGNMENTS:
    try:
        val = getattr(old, attr)
    except AttributeError:
        pass
    else:
        with contextlib.suppress(AttributeError):
            setattr(fn, attr, val)

(The outer try/except/else is fine because it has an else: branch — SIM105 only fires when the body is a bare try/except/pass.)

Log excerpt
SIM105 Use `contextlib.suppress(AttributeError)` instead of `try`-`except`-`pass`
  --> fastmcp_slim/fastmcp/utilities/callable_utils.py:80:17
   |
78 |                 pass
79 |             else:
80 | /               try:
81 | |                   setattr(fn, attr, val)
82 | |               except AttributeError:
83 | |                   pass
   | |__________________________^
84 |
85 |     # Callable classes (not routines, not partials) → unwrap to __call__

Found 1 error. No fixes available (1 hidden fix can be enabled with the --unsafe-fixes option).

Related files
  • fastmcp_slim/fastmcp/utilities/callable_utils.py (lines 80–83) — the only file flagged.
  • PR is still labeled DON'T MERGE / [DNM] in the title, so this fix only needs to land for CI hygiene on the branch.

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

This comment was marked as low quality.

strawgate and others added 2 commits May 12, 2026 22:46
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) <[email protected]>
Addresses Codex P1: when reconstructing a partial to strip __wrapped__,
copy over functools.WRAPPER_ASSIGNMENTS (__module__, __qualname__, etc.)
so deferred annotation resolution works correctly.

Co-authored-by: Copilot <[email protected]>
@strawgate strawgate force-pushed the fix/partial-support branch from eb45781 to 9fd6002 Compare May 13, 2026 03: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: 9fd60025f8

ℹ️ 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 +239 to 240
set_fastmcp_meta(fn, metadata)
self.add_resource(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 classifying resources

For functools.update_wrapper-wrapped partials whose bound arguments leave no callable parameters, this path still hands the original partial to add_resource. add_resource decides static resource vs template with inspect.signature() before prepare_callable() strips __wrapped__, so a static URI like @mcp.resource("data://fixed") on partial(fn, fmt="json") is misclassified as a template and raises ValueError("URI template must contain at least one parameter") instead of registering the resource.

Useful? React with 👍 / 👎.

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]

3 participants