Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/fastmcp/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ def save_contact(name: str, email: str) -> str:

from __future__ import annotations

import inspect
from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import asynccontextmanager, suppress
from typing import Any, Literal, TypeVar, overload
Expand All @@ -38,6 +37,7 @@ def save_contact(name: str, email: str) -> str:
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.tools.base import Tool
from fastmcp.utilities.callable_utils import is_callable_object
from fastmcp.utilities.logging import get_logger

logger = get_logger(__name__)
Expand Down Expand Up @@ -100,7 +100,7 @@ def _dispatch_decorator(
decorator_name: str,
) -> Any:
"""Shared dispatch logic for @app.tool() and @app.ui() calling patterns."""
if inspect.isroutine(name_or_fn):
if is_callable_object(name_or_fn):
return register(name_or_fn, name)
Comment on lines +103 to 104
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 👍 / 👎.


if isinstance(name_or_fn, str):
Expand Down
10 changes: 10 additions & 0 deletions src/fastmcp/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,13 @@ def get_fastmcp_meta(fn: Any) -> Any | None:
except ValueError:
pass
return None


def set_fastmcp_meta(fn: Any, metadata: Any) -> None:
"""Attach FastMCP metadata to a function, handling bound methods.

For bound methods and staticmethods, the metadata is attached to the
underlying ``__func__`` so that ``get_fastmcp_meta`` can find it.
"""
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata
33 changes: 11 additions & 22 deletions src/fastmcp/prompts/function_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import functools
import inspect
import json
import warnings
Expand All @@ -23,7 +22,7 @@
from pydantic.json_schema import SkipJsonSchema

import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.decorators import resolve_task_config, set_fastmcp_meta
from fastmcp.exceptions import FastMCPDeprecationWarning, PromptError
from fastmcp.prompts.base import Prompt, PromptArgument, PromptResult
from fastmcp.server.auth.authorization import AuthCheck
Expand All @@ -36,6 +35,11 @@
call_sync_fn_in_threadpool,
is_coroutine_function,
)
from fastmcp.utilities.callable_utils import (
get_callable_name,
is_callable_object,
prepare_callable,
)
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import get_cached_typeadapter
Expand Down Expand Up @@ -137,9 +141,7 @@ def from_function(
auth=auth,
)

func_name = (
metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__
)
func_name = metadata.name or get_callable_name(fn)

if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
Expand All @@ -158,22 +160,10 @@ def from_function(
else inspect.getdoc(fn)
)

# Normalize task to TaskConfig and validate
task_value = metadata.task
if task_value is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task_value, bool):
task_config = TaskConfig.from_bool(task_value)
else:
task_config = task_value
task_config = TaskConfig.normalize(metadata.task)
task_config.validate_function(fn, func_name)

# if the fn is a callable class, we need to get the __call__ method from here out
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):
fn = fn.__func__
fn = prepare_callable(fn)

# Transform Context type annotations to Depends() for unified DI
fn = transform_context_annotations(fn)
Expand Down Expand Up @@ -452,8 +442,7 @@ def attach_metadata(fn: F, prompt_name: str | None) -> F:
task=task,
auth=auth,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata
set_fastmcp_meta(fn, metadata)
return fn

def decorator(fn: F, prompt_name: str | None) -> F:
Expand All @@ -467,7 +456,7 @@ def decorator(fn: F, prompt_name: str | None) -> F:
return create_prompt(fn, prompt_name) # type: ignore[return-value] # ty:ignore[invalid-return-type]
return attach_metadata(fn, prompt_name)

if inspect.isroutine(name_or_fn):
if is_callable_object(name_or_fn):
return decorator(name_or_fn, name)
elif isinstance(name_or_fn, str):
if name is not None:
Expand Down
34 changes: 11 additions & 23 deletions src/fastmcp/resources/function_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import functools
import inspect
import warnings
from collections.abc import Callable
Expand All @@ -14,7 +13,7 @@
from pydantic.json_schema import SkipJsonSchema

import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.decorators import resolve_task_config, set_fastmcp_meta
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.server.auth.authorization import AuthCheck
Expand All @@ -27,6 +26,11 @@
call_sync_fn_in_threadpool,
is_coroutine_function,
)
from fastmcp.utilities.callable_utils import (
get_callable_name,
is_callable_object,
prepare_callable,
)
from fastmcp.utilities.mime import resolve_ui_mime_type

if TYPE_CHECKING:
Expand Down Expand Up @@ -159,27 +163,12 @@ def from_function(

uri_obj = AnyUrl(metadata.uri)

# Get function name - use class name for callable objects
func_name = (
metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__
)
func_name = metadata.name or get_callable_name(fn)

# Normalize task to TaskConfig and validate
task_value = metadata.task
if task_value is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task_value, bool):
task_config = TaskConfig.from_bool(task_value)
else:
task_config = task_value
task_config = TaskConfig.normalize(metadata.task)
task_config.validate_function(fn, func_name)

# if the fn is a callable class, we need to get the __call__ method from here out
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):
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 👍 / 👎.


# Transform Context type annotations to Depends() for unified DI
fn = transform_context_annotations(fn)
Expand Down Expand Up @@ -259,7 +248,7 @@ def resource(
if isinstance(annotations, dict):
annotations = Annotations(**annotations)

if inspect.isroutine(uri):
if is_callable_object(uri):
raise TypeError(
"The @resource decorator requires a URI. "
"Use @resource('uri') instead of @resource"
Expand Down Expand Up @@ -325,8 +314,7 @@ def attach_metadata(fn: F) -> F:
task=task,
auth=auth,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata
set_fastmcp_meta(fn, metadata)
return fn

def decorator(fn: F) -> F:
Expand Down
19 changes: 4 additions & 15 deletions src/fastmcp/resources/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import functools
import inspect
import re
from collections.abc import Callable
Expand Down Expand Up @@ -30,6 +29,7 @@
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.utilities.callable_utils import get_callable_name, prepare_callable
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.mime import resolve_ui_mime_type
Expand Down Expand Up @@ -488,7 +488,7 @@ def from_function(
) -> FunctionResourceTemplate:
"""Create a template from a function."""

func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
func_name = name or get_callable_name(fn)
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")

Expand Down Expand Up @@ -555,21 +555,10 @@ def from_function(

description = description if description is not None else inspect.getdoc(fn)

# Normalize task to TaskConfig and validate
if task is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task, bool):
task_config = TaskConfig.from_bool(task)
else:
task_config = task
task_config = TaskConfig.normalize(task)
task_config.validate_function(fn, func_name)

# if the fn is a callable class, we need to get the __call__ method from here out
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):
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 👍 / 👎.


# Transform Context type annotations to Depends() for unified DI
fn = transform_context_annotations(fn)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
from mcp.types import AnyFunction

import fastmcp
from fastmcp.decorators import set_fastmcp_meta
from fastmcp.prompts.base import Prompt
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.callable_utils import is_callable_object

if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
Expand Down Expand Up @@ -223,12 +225,11 @@ def decorate_and_register(
auth=auth,
enabled=enabled,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
set_fastmcp_meta(fn, metadata)
self.add_prompt(fn)
return fn

if inspect.isroutine(name_or_fn):
if is_callable_object(name_or_fn):
return decorate_and_register(name_or_fn, name)

elif isinstance(name_or_fn, str):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
from mcp.types import Annotations, AnyFunction

import fastmcp
from fastmcp.decorators import set_fastmcp_meta
from fastmcp.resources.base import Resource
from fastmcp.resources.function_resource import resource as standalone_resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.callable_utils import is_callable_object

if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
Expand Down Expand Up @@ -159,7 +161,7 @@ def get_weather(city: str) -> str:
if isinstance(annotations, dict):
annotations = Annotations(**annotations)

if inspect.isroutine(uri):
if is_callable_object(uri):
raise TypeError(
"The @resource decorator was used incorrectly. "
"It requires a URI as the first argument. "
Expand Down Expand Up @@ -234,8 +236,7 @@ def decorator(fn: AnyFunction) -> Any:
auth=auth,
enabled=enabled,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
set_fastmcp_meta(fn, metadata)
self.add_resource(fn)
return fn

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
from mcp.types import AnyFunction, ToolAnnotations

import fastmcp
from fastmcp.decorators import set_fastmcp_meta
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.callable_utils import is_callable_object
from fastmcp.utilities.types import NotSet, NotSetT

try:
Expand Down Expand Up @@ -396,12 +398,11 @@ def decorate_and_register(
auth=auth,
enabled=enabled,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
set_fastmcp_meta(fn, metadata)
tool_obj = self.add_tool(fn)
return fn

if inspect.isroutine(name_or_fn):
if is_callable_object(name_or_fn):
return decorate_and_register(name_or_fn, name)

elif isinstance(name_or_fn, str):
Expand Down
30 changes: 19 additions & 11 deletions src/fastmcp/server/tasks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@

from __future__ import annotations

import functools
import inspect
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, Literal

from fastmcp.utilities.async_utils import is_coroutine_function
from fastmcp.utilities.callable_utils import prepare_callable

# Task execution modes per SEP-1686 / MCP ToolExecution.taskSupport
TaskMode = Literal["forbidden", "optional", "required"]
Expand Down Expand Up @@ -90,6 +89,23 @@ def from_bool(cls, value: bool) -> TaskConfig:
"""
return cls(mode="optional" if value else "forbidden")

@classmethod
def normalize(cls, task: bool | TaskConfig | None) -> TaskConfig:
"""Convert a task parameter to a TaskConfig.

Args:
task: True/False for simple enable/disable, TaskConfig for full
control, or None for the default (forbidden).

Returns:
A TaskConfig instance.
"""
if task is None:
return cls(mode="forbidden")
if isinstance(task, bool):
return cls.from_bool(task)
return task

def supports_tasks(self) -> bool:
"""Check if this component supports task execution.

Expand Down Expand Up @@ -126,15 +142,7 @@ def validate_function(self, fn: Callable[..., Any], name: str) -> None:
require_docket(f"`task=True` on function '{name}'")

# Unwrap callable classes and staticmethods
fn_to_check = 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__
fn_to_check = prepare_callable(fn)

if not is_coroutine_function(fn_to_check):
raise ValueError(
Expand Down
Loading