Skip to content

Make wrapped Pydantic input tools robust to MCP clients that serialize object args as JSON strings #1

Description

@AndreaV-Lsi

Summary

Tools whose sole parameter is a wrapped Pydantic model named input
(create_work_package, update_work_package, …) fail for any MCP client that serializes
object-typed arguments as a JSON string. Spec-compliant clients that send a JSON object
work unchanged. The defect is in argument-serialization compatibility, not in the server's
business logic.

Goal: a general, widely-consumable fix so the server works with any MCP client regardless of
how it serializes structured tool arguments — not a workaround for one product.

Symptom

1 validation error for call[create_work_package]
input
  Input should be a valid dictionary or instance of CreateWorkPackageInput
  [type=model_type, input_value='{"project_id": 458, ...}', input_type=str]

input_value is valid JSON, but it arrived as a str.

Root cause

@mcp.tool
async def create_work_package(input: CreateWorkPackageInput) -> str:
    ...

FastMCP publishes input with an opaque object schema (an unconstrained / $ref shape, not
an explicit type: object) and validates the argument against the model before the function
body runs
. So success depends entirely on how the client encodes the argument:

  • Client sends input as a JSON object → validates to the model → works.
  • Client serializes object-typed args as a JSON string (common when the parameter schema
    isn't an explicit object) → FastMCP receives a str → rejects it before the body. An in-body
    json.loads cannot help, because validation happens first.

Evidence it is client-side, not server-side

Two independent pristine clones (same HEAD, no local diffs, no edits):

  • Driven by a spec-compliant client (Claude Code / VS Code extension): work-package creation
    succeeds unpatched — the client sends input as an object.
  • Driven by a client that stringifies object args: the model_type error above.

Within the failing client, read tools and flat-parameter tools work fine
(e.g. set_work_package_parent(child_id, parent_id), every list_*). Only the
single-wrapped-input tools fail. This localizes the issue precisely to serialization of object
arguments under an opaque parameter schema.

Why fix it server-side

The MCP ecosystem has many clients and bridges. Tolerating both encodings is cheap server hygiene
that broadens compatibility to the whole class of "stringifying" clients without asking any of
them to change. It is not specific to any one product.

Scope — affected tools (verified against current HEAD)

grep -rn "async def .*(input:" src/tools17 functions across 8 files:

File Functions
src/tools/work_packages.py create_work_package, update_work_package
src/tools/relations.py create_work_package_relation, update_work_package_relation
src/tools/projects.py create_project, add_subproject, update_project
src/tools/versions.py create_version
src/tools/time_entries.py create_time_entry, update_time_entry
src/tools/memberships.py create_membership, update_membership
src/tools/news.py create_news, update_news
src/tools/weekly_reports.py generate_weekly_report, get_report_data, plus internal _generate_weekly_report_impl (verify call path)

Whatever fix is chosen should ideally cover any future model-input tool automatically.

Candidate solution directions (to be discussed before implementation)

To be evaluated in the issue discussion — not yet decided:

  1. Decorator / wrapper that auto-detects the model parameter and coerces a str argument via
    json.loads → model before the body runs.
  2. Pydantic BeforeValidator / model_validator(mode="before") on each input model (or a
    shared base model) that accepts str | dict.
  3. Flatten the signatures — drop the input wrapper and expose explicit fields so FastMCP
    emits an explicit type: object schema (larger API change; affects docs/tests).
  4. Custom type annotation (Annotated[Model, BeforeValidator(...)]) applied uniformly.

Each has trade-offs in blast radius, schema impact, test surface, and "covers future tools
automatically." We will compare these (and a parallel proposal from another session) here before
writing code.

Acceptance criteria

  • A client that sends input as a JSON string can successfully call all affected tools.
  • A spec-compliant client that sends input as a JSON object continues to work (no regression).
  • The fix covers all 17 current call sites and, ideally, any future model-input tool without
    per-tool boilerplate.
  • The published tool JSON schema remains valid; no breaking change for already-working clients.
  • Tests cover both encodings (object and stringified) for at least a representative tool.
  • uv run black . / uv run flake8 . clean.

Out of scope

  • Changing OpenProject API client logic or any tool's business behavior.
  • Reworking flat-parameter tools (they already work).
  • Bumping/pinning FastMCP beyond what the fix requires.

References

  • Affected files: see scope table.
  • FastMCP >=2.0.0, pydantic >=2.0.0 (pyproject.toml).
  • Local planning mirror: LOCAL_PR_BACKLOG.md (to be promoted to this issue's PR).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions