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.
This commit is contained in:
Jeremiah Lowin 2025-11-17 12:25:35 -05:00
commit 52100b08ff
5 changed files with 105 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -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."""

View file

@ -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"]