Skip to content

Pydantic model parameter not flattened in MCP tool schema — breaks SDK clients that send flat argument dicts #3637

Closed as not planned

Description

@alan-atkinson

Initial Checks

Release line

2.x (current stable)

Description

Description

When a FastMCP tool is defined with a Pydantic model as its parameter, FastMCP 4.x generates a JSON schema that wraps the model's fields under a "request" key rather than flattening them to the top level. This breaks MCP clients that send the fields flat — including the official adcp Python SDK, which serialises request objects with model.model_dump() and sends the result as a flat dict.

What happens:

Tool defined as:


async def get_adcp_capabilities(
    request: GetAdcpCapabilitiesRequest = GetAdcpCapabilitiesRequest(),
    ctx: Context | None = None,
) -> ToolResult:
    ...

FastMCP generates this inputSchema:

{
  "type": "object",
  "properties": {
    "request": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "adcp_version": { ... },
        "protocols": { ... }
      }
    }
  },
  "additionalProperties": false
}

Client sends:

{ "adcp_version": "3.2", "protocols": null }
FastMCP validates this against the schema and raises:

1 validation error for call[get_adcp_capabilities]
adcp_version
Unexpected keyword argument [type=unexpected_keyword_argument, ...]
What should happen:

Either:

  1. FastMCP should flatten the fields of a Pydantic model parameter into the tool's top-level inputSchema (matching how bare field: type = default kwargs work today), or
  2. FastMCP should accept both the flat form {"adcp_version": "3.2"} and the nested form {"request": {"adcp_version": "3.2"}} when the parameter is a Pydantic model.

The spec (GetAdcpCapabilitiesRequest) already defines the full wire shape including extra: 'allow' for forward compatibility. Using the model type as the parameter should be the idiomatic approach — it avoids re-declaring every field individually.

Workaround: Redeclare every field from the Pydantic model as individual kwargs on the tool function (defeats the purpose of having the model type).

Example Code

from pydantic import BaseModel
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("example")

class MyRequest(BaseModel):
    model_config = {"extra": "allow"}
    adcp_version: str | None = None
    protocols: list[str] | None = None

@mcp.tool()
async def my_tool(request: MyRequest = MyRequest()) -> str:
    return f"got: {request.adcp_version}"

# Client sends: {"adcp_version": "3.2"}
# FastMCP raises: Unexpected keyword argument 'adcp_version'
# Client must send: {"request": {"adcp_version": "3.2"}}
# — but the SDK and spec generate flat dicts from model_dump()

Python & MCP Python SDK

Python: 3.12
FastMCP: 4.0.3
mcp Python SDK: 2.x (latest)

Activity

  1. aritejhg commented on Oct 3, 2026

    @aritejhg

    Could I take this up with the following approach?

    • Confirm scope: reproduced unexpected_keyword_argument in standalone FastMCP 4.0.3; SDK 2.x MCPServer instead ignores flat fields when the model parameter has a default.
    • Preserve the documented nested contract; clarify model envelopes and the low-level Server route for flat wire schemas. Draft docs: clarify Pydantic tool argument envelopes #3638 contains documentation and regression coverage; 90 relevant tests passed locally.
    • If flat/dual-shape runtime support is preferred, agree on the published schema and conflict rules first, while preserving existing nested callers.

    Investigation and draft prepared with Codex assistance.

  2. Ben-Home commented on Oct 5, 2026

    @Ben-Home

    The wrapper appears mechanically (the parameter name becomes a property), but the design question is which shape the schema should advertise, and that should be settled by what clients actually send. model_dump() produces the flat dict, your client and the reference SDK both serialize flat, so the schema and the serialization disagree today. That is the bug, and it means the flatten direction is the correct default for a top-level model parameter, with two guardrails:

    1. Flatten only when it is unambiguous. A single model parameter can be treated like a body object and flattened. Two model parameters cannot, because flattened field names would collide and the namespacing is lost. Detect the unambiguous case and keep the wrapper otherwise.
    2. Decide alias semantics deliberately. There is already a validation-mode versus serialization-mode split for aliases and computed fields in this area; whichever mode governs the flattened property names needs to be chosen explicitly, or the aliased-field class reappears inside the new shape.

    Accepting both shapes is worth having as a transitional shim, not as the contract: silently normalizing both forms means two wire formats for the same tool, which is exactly the ambiguity that makes cross-SDK interop painful. If you do it, do it loudly (accept both for a deprecation window, emit a server-side warning when the nested form arrives).

    A test matrix that would pin this down: model as the only parameter; model plus primitive parameters; optional model with a default; aliased fields under both modes; extra set to allow. The current workaround of redeclaring every field as kwargs is the right escape hatch, but it is precisely the duplication the model type exists to remove.

    This is the same rule we follow for connector schemas around corpusiq.io: generate the schema from the same serialization path that produces the payload, never a parallel one.

  3. maxisbey commented on Oct 6, 2026

    @maxisbey
    Contributor

    Thanks for the report. I'm going to close this one, because the error you're seeing comes from the standalone FastMCP package (4.0.3 in your environment) rather than this SDK. Unexpected keyword argument is raised by its own argument validation, so https://github.com/PrefectHQ/fastmcp is the right place for it.

    AI Disclaimer


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions