Fix: Include signature modification in create_function_without_params (#2563)

* Fix: Include signature modification in create_function_without_params

When excluding parameters via create_function_without_params(), only
__annotations__ was being updated but not __signature__. This caused
Pydantic's _arguments_schema() to fail when it iterated over signature
parameters that didn't exist in the type hints dictionary.

The fix adds proper signature reconstruction matching the pattern used
in without_injected_parameters().
Fixes KeyError: 'ctx' when using @mcp.tool() with Context parameters.

* fix: add regression tests for create_function_without_params

The test_pydantic_typeadapter_compatibility test specifically reproduces the issue from #2562 and verifies the fix.

* fix: linter for test function

---------

Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Aidan Allchin 2025-12-06 08:57:36 -08:00 committed by GitHub
commit b9cde3bdb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 0 deletions

View file

@ -206,6 +206,13 @@ def create_function_without_params(
k: v for k, v in original_annotations.items() if k not in exclude_params
}
# Create new signature without the excluded parameters
sig = inspect.signature(fn)
new_params = [
param for name, param in sig.parameters.items() if name not in exclude_params
]
new_sig = inspect.Signature(new_params, return_annotation=sig.return_annotation)
new_func = types.FunctionType(
code,
globals_dict,
@ -217,6 +224,7 @@ def create_function_without_params(
new_func.__module__ = fn.__module__
new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute]
new_func.__annotations__ = new_annotations
new_func.__signature__ = new_sig # type: ignore[attr-defined]
if inspect.ismethod(fn):
return types.MethodType(new_func, fn.__self__)

View file

@ -10,6 +10,7 @@ from fastmcp.utilities.types import (
Audio,
File,
Image,
create_function_without_params,
get_cached_typeadapter,
is_class_member_of_type,
issubclass_safe,
@ -518,6 +519,87 @@ class TestReplaceType:
assert replace_type(input, type_map) == expected
class TestCreateFunctionWithoutParams:
"""Test create_function_without_params properly removes parameters from both annotations and signature."""
def test_removes_params_from_both_annotations_and_signature(self):
"""Test that excluded parameters are removed from __annotations__ AND __signature__."""
import inspect
def original_func(ctx: str, query: str, limit: int = 10) -> list[str]:
return []
new_func = create_function_without_params(original_func, ["ctx"])
# Verify removal from annotations
assert "ctx" not in new_func.__annotations__
assert "query" in new_func.__annotations__
assert "limit" in new_func.__annotations__
# Verify removal from signature (regression test for #2562)
sig = inspect.signature(new_func)
assert "ctx" not in sig.parameters
assert "query" in sig.parameters
assert "limit" in sig.parameters
def test_preserves_return_annotation_in_signature(self):
"""Test that return annotation is preserved in both annotations and signature."""
import inspect
def original_func(ctx: str, value: int) -> dict[str, int]:
return {}
new_func = create_function_without_params(original_func, ["ctx"])
sig = inspect.signature(new_func)
assert sig.return_annotation == dict[str, int]
assert new_func.__annotations__["return"] == dict[str, int]
def test_pydantic_typeadapter_compatibility(self):
"""Test that modified function works with Pydantic TypeAdapter (regression test for #2562)."""
from pydantic import BaseModel
class Result(BaseModel):
name: str
def tool_function(ctx: str, search_query: str, limit: int = 10) -> list[Result]:
return []
# Remove context parameter (what FastMCP does internally)
new_func = create_function_without_params(tool_function, ["ctx"])
# This raised KeyError: 'ctx' before the fix
adapter = get_cached_typeadapter(new_func)
schema = adapter.json_schema()
# Verify schema excludes the removed parameter
assert "properties" in schema
assert "ctx" not in schema["properties"]
assert "search_query" in schema["properties"]
assert "limit" in schema["properties"]
def test_multiple_excluded_parameters(self):
"""Test excluding multiple parameters simultaneously."""
import inspect
def func(ctx: str, session: int, query: str, limit: int = 5) -> str:
return ""
new_func = create_function_without_params(func, ["ctx", "session"])
sig = inspect.signature(new_func)
# Both excluded params should be removed
assert "ctx" not in sig.parameters
assert "session" not in sig.parameters
assert "ctx" not in new_func.__annotations__
assert "session" not in new_func.__annotations__
# Non-excluded params should remain
assert "query" in sig.parameters
assert "limit" in sig.parameters
class TestAnnotationStringDescriptions:
"""Test the new functionality for string descriptions in Annotated types."""