Skip to content

Conversation

@pabyome
Copy link

@pabyome pabyome commented Nov 28, 2025

Description

Problem & Solution
Currently, when a Pydantic model is used as a type hint for a tool argument in FastMCP, it is exposed in the tool's JSON schema as a single nested object parameter. This requires Language Models (LLMs) to construct a complex nested JSON structure to call the tool, which can be less intuitive and more prone to structural errors compared to flat argument lists.

This PR introduces a new
unpack_pydantic_args
parameter to the @tool decorator (and Tool.from_function). When set to True, FastMCP inspects Pydantic model arguments and "unpacks" their fields into individual top-level arguments in the generated tool schema. When the tool is called, FastMCP automatically reconstructs the Pydantic model instances from the flat arguments before invoking the underlying function. This provides a cleaner, more "function-like" interface for LLMs while maintaining the type safety and validation benefits of Pydantic models within the application code.

from pydantic import BaseModel, Field
from fastmcp import FastMCP

# Define a Pydantic model
class User(BaseModel):
    name: str = Field(description="The user's name")
    age: int = Field(description="The user's age")

mcp = FastMCP("demo")

# Enable argument unpacking
@mcp.tool(unpack_pydantic_args=True)
def greet_user(user: User, greeting: str = "Hello") -> str:
    return f"{greeting}, {user.name}! You are {user.age} years old."

# The generated schema will look like this (simplified):
# {
#   "type": "object",
#   "properties": {
#     "name": {"type": "string", "description": "The user's name"},
#     "age": {"type": "integer", "description": "The user's age"},
#     "greeting": {"type": "string", "default": "Hello"}
#   },
#   "required": ["name", "age"]
# }
#
# The LLM calls it as: call_tool("greet_user", name="Alice", age=30)
# The function receives: user=User(name="Alice", age=30), greeting="Hello"

Contributors Checklist

  • My change closes #(issue number)
  • I have followed the repository's development workflow
  • I have tested my changes manually and by adding relevant tests
  • I have performed all required documentation updates

Review Checklist

  • I have self-reviewed my changes
  • My Pull Request is ready for review

@marvin-context-protocol marvin-context-protocol bot added the enhancement Improvement to existing functionality. For issues and smaller PR improvements. label Nov 28, 2025
@marvin-context-protocol
Copy link
Contributor

Test Failure Analysis

Summary: The static analysis job failed due to a type checking error when dynamically setting the __signature__ attribute on a function object.

Root Cause: In src/fastmcp/tools/tool.py:531, the code attempts to dynamically assign a signature to a function object:

placeholder_fn.__signature__ = new_sig
placeholder_fn.__annotations__ = new_annotations

Python's type checker (ty) doesn't recognize that function objects can have these attributes dynamically assigned, resulting in an unresolved-attribute error. While this is valid at runtime (Python allows these assignments), the static type checker doesn't know about these writable attributes on function types.

Suggested Solution: Add type ignore comments to suppress the type checker warnings for these dynamic attribute assignments:

placeholder_fn.__signature__ = new_sig  # type: ignore[attr-defined]
placeholder_fn.__annotations__ = new_annotations  # type: ignore[attr-defined]

This is the standard pattern used elsewhere in the FastMCP codebase for similar situations (see AGENTS.md line 116: "Use # type: ignore[attr-defined] in tests for MCP results instead of type assertions").


Detailed Analysis

Error Output

error[unresolved-attribute]: Unresolved attribute `__signature__` on type `def placeholder_fn(...) -> Unknown`.
   --> src/fastmcp/tools/tool.py:531:17
    |
531 |                 placeholder_fn.__signature__ = new_sig
    |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
532 |                 placeholder_fn.__annotations__ = new_annotations

Why This Happens

The feature being added unpacks Pydantic model arguments into flat parameters for the tool schema. To achieve this, the code creates a placeholder function and dynamically modifies its signature and annotations to reflect the unpacked parameters. While Python allows this at runtime, static type checkers don't have type information for these writable attributes on function objects.

Alternative Approaches

  1. Type ignore comments (recommended): Simplest and most direct
  2. Using setattr(): Would work but less readable (setattr(placeholder_fn, '__signature__', new_sig))
  3. Using cast(): Could cast to Any but that's more verbose

The type ignore approach is cleanest and follows the project's existing patterns.

Related Files
  • src/fastmcp/tools/tool.py:531-532 - Lines that need type ignore comments

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 28, 2025

Walkthrough

This pull request introduces support for unpacking Pydantic model arguments into individual fields when registering tools. A new unpack_pydantic_args: bool = False parameter is added to the tool decorator in the FastMCP server class and propagated through the tool registration workflow. Corresponding changes in the tool module add unpack_pydantic_args and unpacked_models_map attributes to FunctionTool and ParsedFunction. When enabled, the feature generates an unpacked signature with model fields as individual parameters for schema generation, then reassembles arguments back into Pydantic model instances at runtime before invoking the wrapped function.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description check ❓ Inconclusive The description is comprehensive, covering problem, solution, and concrete examples; however, the contributors checklist has an unchecked issue-closure requirement and the documentation updates checkbox remains unchecked. Verify that an associated GitHub issue exists and is referenced, and confirm whether documentation updates are needed or if this is a deliberate omission. Update the checklist status accordingly.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Feat/unpack pydantic args' is concise and directly describes the main feature addition—unpacking Pydantic model arguments into individual top-level parameters.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/fastmcp/tools/tool.py (2)

259-260: Add stricter type annotation for unpacked_models_map.

The type dict | None is too loose. For better type safety and documentation, specify the expected key-value types.

-    unpack_pydantic_args: bool = False
-    unpacked_models_map: dict | None = None
+    unpack_pydantic_args: bool = False
+    unpacked_models_map: dict[str, type] | None = None

Note: Using type instead of type[pydantic.BaseModel] avoids a forward reference issue, but the intent could also be documented in a comment.


494-495: Optional: Import pydantic at module level.

Since pydantic is already imported at the module level (Line 21: from pydantic import Field, ...), the conditional import here is redundant. However, this is a minor style preference and doesn't affect functionality.

-        if unpack_pydantic_args:
-            import pydantic
+        if unpack_pydantic_args:
+            from pydantic import BaseModel as PydanticBaseModel

Or add BaseModel to the existing import at line 21 and use it directly.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ba69fba and 6d13e95.

⛔ Files ignored due to path filters (2)
  • tests/test_unpack_pydantic_args.py is excluded by none and included by none
  • tests/tools/test_tool.py is excluded by none and included by none
📒 Files selected for processing (2)
  • src/fastmcp/server/server.py (5 hunks)
  • src/fastmcp/tools/tool.py (11 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
src/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.py: Python source code must use Python ≥3.10 with full type annotations
Never use bare except - be specific with exception types
Follow Ruff linting and Prettier formatting standards - run uv run prek run --all-files before committing
Prioritize readable, understandable code - clarity over cleverness, avoid obfuscated or confusing patterns

Files:

  • src/fastmcp/server/server.py
  • src/fastmcp/tools/tool.py
🪛 GitHub Actions: Run static analysis
src/fastmcp/tools/tool.py

[error] 543-543: ruff-check failed. Unresolved attribute __signature__ on type def placeholder_fn(...) -> Unknown.


[error] 1-1: ruff-format hook reformatted 1 file (1 file reformatted, 379 files left unchanged).


[error] 1-1: ty check failed during type checking. Diagnostics reported by the type checker (multiple attribute/missing-field warnings).

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Run tests with lowest-direct dependencies
  • GitHub Check: Run tests: Python 3.10 on windows-latest
  • GitHub Check: label-issue-or-pr
🔇 Additional comments (3)
src/fastmcp/server/server.py (1)

1383-1384: LGTM! Parameter threading is correct and consistent.

The unpack_pydantic_args parameter is properly added to all overloads and forwarded through both the direct registration path (Line 1507) and the deferred decoration path via partial (Line 1541).

Also applies to: 1401-1402, 1418-1419, 1507-1508, 1541-1542

src/fastmcp/tools/tool.py (2)

347-367: Reassembly logic is correct, assuming field collisions are prevented.

The logic properly:

  1. Iterates through unpacked models and their expected fields
  2. Collects matching argument values
  3. Constructs model instances
  4. Tracks consumed keys to avoid passing them through again
  5. Passes remaining arguments as-is

This works correctly when field names are unique, which depends on the validation suggested in the previous comment.


191-208: LGTM! Parameter and data threading is correct.

The unpack_pydantic_args parameter and unpacked_models_map are correctly threaded through:

  • Tool.from_functionFunctionTool.from_function
  • FunctionTool.from_functionParsedFunction.from_function
  • ParsedFunctionFunctionTool instance attributes

Also applies to: 291-295, 337-339

Comment on lines +491 to +533
unpacked_models_map = {}
fn_for_schema = fn

if unpack_pydantic_args:
import pydantic

original_sig = inspect.signature(fn)
new_params = []
has_pydantic_model = False

for param in original_sig.parameters.values():
# Check if the parameter type is a Pydantic model
if isinstance(param.annotation, type) and issubclass(param.annotation, pydantic.BaseModel):
has_pydantic_model = True
unpacked_models_map[param.name] = param.annotation
# Unpack the model's fields into new parameters
for field_name, field in param.annotation.model_fields.items():
# Create a new parameter for each field
new_param = inspect.Parameter(
name=field_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=field.default if not field.is_required() else inspect.Parameter.empty,
annotation=field.annotation,
)
new_params.append(new_param)
else:
new_params.append(param)

if has_pydantic_model:
# Sort parameters to ensure non-defaults come first
required_params = [p for p in new_params if p.default == inspect.Parameter.empty]
optional_params = [p for p in new_params if p.default != inspect.Parameter.empty]
new_params = required_params + optional_params

# Create a new function with the unpacked signature for schema generation
def placeholder_fn(*args, **kwargs): ...

new_sig = original_sig.replace(parameters=new_params)
new_annotations = {p.name: p.annotation for p in new_params if p.annotation != inspect.Parameter.empty}

placeholder_fn.__signature__ = new_sig
placeholder_fn.__annotations__ = new_annotations
fn_for_schema = placeholder_fn
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Field name collisions are not handled.

The unpacking logic doesn't validate or handle field name collisions. This can cause silent data corruption or schema generation errors in several scenarios:

  1. Multiple Pydantic models with overlapping field names:

    class User(BaseModel):
        name: str
    class Admin(BaseModel):
        name: str
    
    @tool(unpack_pydantic_args=True)
    def process(user: User, admin: Admin):  # Both have 'name'
        ...
  2. Pydantic field collides with non-Pydantic parameter:

    class User(BaseModel):
        name: str
    
    @tool(unpack_pydantic_args=True)  
    def greet(name: str, user: User):  # 'name' appears twice
        ...

Add validation to detect and reject conflicting field names:

         if unpack_pydantic_args:
             import pydantic

             original_sig = inspect.signature(fn)
             new_params = []
             has_pydantic_model = False
+            seen_field_names: set[str] = set()
+
+            # First pass: collect non-Pydantic param names
+            for param in original_sig.parameters.values():
+                if not (isinstance(param.annotation, type) and issubclass(param.annotation, pydantic.BaseModel)):
+                    if param.name in seen_field_names:
+                        raise ValueError(f"Duplicate parameter name: {param.name}")
+                    seen_field_names.add(param.name)

             for param in original_sig.parameters.values():
                 # Check if the parameter type is a Pydantic model
                 if isinstance(param.annotation, type) and issubclass(param.annotation, pydantic.BaseModel):
                     has_pydantic_model = True
                     unpacked_models_map[param.name] = param.annotation
                     # Unpack the model's fields into new parameters
                     for field_name, field in param.annotation.model_fields.items():
+                        if field_name in seen_field_names:
+                            raise ValueError(
+                                f"Field name '{field_name}' from Pydantic model '{param.annotation.__name__}' "
+                                f"conflicts with another parameter. Cannot unpack."
+                            )
+                        seen_field_names.add(field_name)
                         # Create a new parameter for each field
                         new_param = inspect.Parameter(


for param in original_sig.parameters.values():
# Check if the parameter type is a Pydantic model
if isinstance(param.annotation, type) and issubclass(param.annotation, pydantic.BaseModel):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Pipeline failure: Line exceeds length limit or needs reformatting.

The pipeline indicates ruff-format reformatted this file. This line with the compound isinstance/issubclass check may be the culprit.

Consider reformatting for line length compliance:

-                if isinstance(param.annotation, type) and issubclass(param.annotation, pydantic.BaseModel):
+                if (
+                    isinstance(param.annotation, type)
+                    and issubclass(param.annotation, pydantic.BaseModel)
+                ):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if isinstance(param.annotation, type) and issubclass(param.annotation, pydantic.BaseModel):
if (
isinstance(param.annotation, type)
and issubclass(param.annotation, pydantic.BaseModel)
):
🤖 Prompt for AI Agents
In src/fastmcp/tools/tool.py around line 503, the compound check "if
isinstance(param.annotation, type) and issubclass(param.annotation,
pydantic.BaseModel):" is too long for the formatter; refactor it to meet
line-length rules by splitting the expression across multiple lines or assigning
the annotation to a local variable first (e.g., ann = param.annotation) and then
perform the two checks on separate lines using short names and proper
indentation so the line length limit is not exceeded.

Comment on lines +509 to +515
new_param = inspect.Parameter(
name=field_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=field.default if not field.is_required() else inspect.Parameter.empty,
annotation=field.annotation,
)
new_params.append(new_param)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential issue with default_factory fields.

When a Pydantic field uses default_factory instead of a direct default value, field.is_required() returns False but field.default is PydanticUndefined. This will cause the unpacked parameter to incorrectly use PydanticUndefined as its default value.

                     for field_name, field in param.annotation.model_fields.items():
                         # Create a new parameter for each field
+                        # Handle default vs default_factory
+                        if field.is_required():
+                            default = inspect.Parameter.empty
+                        elif field.default is not pydantic_core.PydanticUndefined:
+                            default = field.default
+                        else:
+                            # Field has default_factory - cannot represent as static default
+                            # Use empty to make it required in schema (factory runs at validation)
+                            default = inspect.Parameter.empty
                         new_param = inspect.Parameter(
                             name=field_name,
                             kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
-                            default=field.default if not field.is_required() else inspect.Parameter.empty,
+                            default=default,
                             annotation=field.annotation,
                         )

Note: Fields with default_factory will appear as required in the unpacked schema since we can't represent factory functions as static defaults. Consider documenting this limitation.

🤖 Prompt for AI Agents
In src/fastmcp/tools/tool.py around lines 509 to 515, the code uses
field.default even when a field has a default_factory (where field.is_required()
is False but field.default == PydanticUndefined), which would set the parameter
default to PydanticUndefined; change the logic to detect PydanticUndefined (from
pydantic.fields import Undefined or PydanticUndefined) and treat such fields as
having no static default by using inspect.Parameter.empty, and mark them as
required in the unpacked signature (and add a short inline comment or doc note
that fields with default_factory cannot expose the factory as a static default
and therefore appear required).

Comment on lines +531 to +532
placeholder_fn.__signature__ = new_sig
placeholder_fn.__annotations__ = new_annotations
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix pipeline failure: Add type ignore comment for __signature__ assignment.

The pipeline is failing because the type checker doesn't recognize that __signature__ can be assigned to function objects. This is valid Python for customizing function introspection.

-                placeholder_fn.__signature__ = new_sig
+                placeholder_fn.__signature__ = new_sig  # type: ignore[attr-defined]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
placeholder_fn.__signature__ = new_sig
placeholder_fn.__annotations__ = new_annotations
placeholder_fn.__signature__ = new_sig # type: ignore[attr-defined]
placeholder_fn.__annotations__ = new_annotations
🤖 Prompt for AI Agents
In src/fastmcp/tools/tool.py around lines 531-532, the assignment to
placeholder_fn.__signature__ triggers the type checker because __signature__ is
not recognized as an attribute on arbitrary function objects; update the
assignment line to append a type-ignore comment (e.g. "# type:
ignore[attr-defined]") so the type checker ignores this attribute assignment and
leave the existing placeholder_fn.__annotations__ assignment unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality. For issues and smaller PR improvements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant