From b9cde3bdb680fbd77b45cfbc855d241005c8373f Mon Sep 17 00:00:00 2001 From: Aidan Allchin Date: Sat, 6 Dec 2025 08:57:36 -0800 Subject: [PATCH] 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> --- src/fastmcp/utilities/types.py | 8 ++++ tests/utilities/test_types.py | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 1446d2813..4c2400e73 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -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__) diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 3b74c0696..fb96e12d0 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -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."""