From 52100b08ff6e8a52d2ddbedf8877b2ea03f56d70 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:25:35 -0500 Subject: [PATCH] Fix exclude_args with non-serializable types Fixes issue #2431 where exclude_args fails when excluded parameters have non-serializable types (e.g., ServerSession). The fix excludes parameters from function annotations before Pydantic tries to serialize them. Also adds deprecation notice that exclude_args will be deprecated in FastMCP 2.14 in favor of dependency injection. --- docs/servers/tools.mdx | 4 ++- src/fastmcp/server/server.py | 5 ++- src/fastmcp/tools/tool.py | 20 ++++++++++- src/fastmcp/utilities/types.py | 49 ++++++++++++++++++++++++++ tests/server/test_tool_exclude_args.py | 30 ++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index e326258d2..92812378d 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -354,7 +354,9 @@ Field provides several validation and documentation features: You can exclude certain arguments from the tool schema shown to the LLM. This is useful for arguments that are injected at runtime (such as `state`, `user_id`, or credentials) and should not be exposed to the LLM or client. Only arguments with default values can be excluded; attempting to exclude a required argument will raise an error. -Example: +**Note:** `exclude_args` will be deprecated in FastMCP 2.14 in favor of dependency injection with `Depends()` for better lifecycle management and more explicit dependency handling. `exclude_args` will continue to work until then. + +Example with `exclude_args`: ```python @mcp.tool( diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 5f3931dda..597c15dfa 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1434,7 +1434,9 @@ class FastMCP(Generic[LifespanResultT]): tags: Optional set of tags for categorizing the tool output_schema: Optional JSON schema for the tool's output annotations: Optional annotations about the tool's behavior - exclude_args: Optional list of argument names to exclude from the tool schema + exclude_args: Optional list of argument names to exclude from the tool schema. + Note: `exclude_args` will be deprecated in FastMCP 2.14 in favor of dependency + injection with `Depends()` for better lifecycle management. meta: Optional meta information about the tool enabled: Optional boolean to enable or disable the tool @@ -1485,6 +1487,7 @@ class FastMCP(Generic[LifespanResultT]): tool_name = name # Use keyword name if provided, otherwise None # Register the tool immediately and return the tool object + # Note: Deprecation warning for exclude_args is handled in Tool.from_function tool = Tool.from_function( fn, name=tool_name, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index baa902471..552c4d729 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -32,6 +32,7 @@ from fastmcp.utilities.types import ( Image, NotSet, NotSetT, + create_function_without_params, find_kwarg_by_type, get_cached_typeadapter, replace_type, @@ -271,6 +272,16 @@ class FunctionTool(Tool): enabled: bool | None = None, ) -> FunctionTool: """Create a Tool from a function.""" + if exclude_args and fastmcp.settings.deprecation_warnings: + warnings.warn( + "The `exclude_args` parameter will be deprecated in FastMCP 2.14. " + "We recommend using dependency injection with `Depends()` instead, which provides " + "better lifecycle management and is more explicit. " + "`exclude_args` will continue to work until then. " + "See https://gofastmcp.com/docs/servers/tools for examples.", + DeprecationWarning, + stacklevel=2, + ) parsed_fn = ParsedFunction.from_function(fn, exclude_args=exclude_args) @@ -442,7 +453,14 @@ class ParsedFunction: if exclude_args: prune_params.extend(exclude_args) - input_type_adapter = get_cached_typeadapter(fn) + # Create a function without excluded parameters in annotations + # This prevents Pydantic from trying to serialize non-serializable types + # before we can exclude them in compress_schema + fn_for_typeadapter = fn + if prune_params: + fn_for_typeadapter = create_function_without_params(fn, prune_params) + + input_type_adapter = get_cached_typeadapter(fn_for_typeadapter) input_schema = input_type_adapter.json_schema() input_schema = compress_schema( input_schema, prune_params=prune_params, prune_titles=True diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 6bbdab6fc..1446d2813 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -175,6 +175,55 @@ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None: return None +def create_function_without_params( + fn: Callable[..., Any], exclude_params: list[str] +) -> Callable[..., Any]: + """ + Create a new function with the same code but without the specified parameters in annotations. + + This is used to exclude parameters from type adapter processing when they can't be serialized. + The excluded parameters are removed from the function's __annotations__ dictionary. + """ + import types + + if inspect.ismethod(fn): + actual_func = fn.__func__ + code = actual_func.__code__ # ty: ignore[unresolved-attribute] + globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] + name = actual_func.__name__ # ty: ignore[unresolved-attribute] + defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] + closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] + else: + code = fn.__code__ # ty: ignore[unresolved-attribute] + globals_dict = fn.__globals__ # ty: ignore[unresolved-attribute] + name = fn.__name__ # ty: ignore[unresolved-attribute] + defaults = fn.__defaults__ # ty: ignore[unresolved-attribute] + closure = fn.__closure__ # ty: ignore[unresolved-attribute] + + # Create a copy of annotations without the excluded parameters + original_annotations = getattr(fn, "__annotations__", {}) + new_annotations = { + k: v for k, v in original_annotations.items() if k not in exclude_params + } + + new_func = types.FunctionType( + code, + globals_dict, + name, + defaults, + closure, + ) + new_func.__dict__.update(fn.__dict__) + new_func.__module__ = fn.__module__ + new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute] + new_func.__annotations__ = new_annotations + + if inspect.ismethod(fn): + return types.MethodType(new_func, fn.__self__) + else: + return new_func + + class Image: """Helper class for returning images from tools.""" diff --git a/tests/server/test_tool_exclude_args.py b/tests/server/test_tool_exclude_args.py index fec695f88..759dd5811 100644 --- a/tests/server/test_tool_exclude_args.py +++ b/tests/server/test_tool_exclude_args.py @@ -1,6 +1,7 @@ from typing import Any import pytest +from mcp.server.session import ServerSession from fastmcp import Client, FastMCP from fastmcp.tools.tool import Tool @@ -92,3 +93,32 @@ async def test_tool_functionality_with_exclude_args(): "create_item", {"name": "test_item", "value": 42} ) assert result.data == {"name": "test_item", "value": 42} + + +async def test_exclude_args_with_non_serializable_type(): + """Test that exclude_args works even when the excluded parameter type can't be serialized. + + This test ensures that exclude_args works correctly when the excluded parameter + has a type that Pydantic cannot serialize (like ServerSession). The bug was that + get_cached_typeadapter would try to serialize all parameters before compress_schema + could exclude them, causing a PydanticSchemaGenerationError. + """ + + def my_tool(message: str, session: ServerSession | None = None) -> str: + """A tool that takes a non-serializable Session parameter.""" + return message + + # This should not raise an error even though ServerSession can't be serialized + tool = Tool.from_function( + my_tool, + name="my_tool", + exclude_args=["session"], + ) + + # Verify the tool was created successfully + assert tool is not None + assert tool.name == "my_tool" + + # Verify the session parameter is excluded from the schema + assert "session" not in tool.parameters["properties"] + assert "message" in tool.parameters["properties"]