Skip to content

fix(server): wrap pydantic ValidationError from tool arg validation as fastmcp ValidationError - #4172

Closed
SarthakB11 wants to merge 1 commit into
PrefectHQ:mainfrom
SarthakB11:fix/issue-4128
Closed

SarthakB11 wants to merge 1 commit into
PrefectHQ:mainfrom
SarthakB11:fix/issue-4128

Conversation

@SarthakB11

Copy link
Copy Markdown
Contributor

Description

When invalid arguments are passed to a tool, pydantic-core's call validator raises a raw pydantic.ValidationError which FastMCP.call_tool re-raises unchanged. The error then propagates through the middleware chain as a non-fastmcp type, so consumers filtering on the framework's own error taxonomy (e.g. a Sentry ignore_errors=[fastmcp.exceptions.ValidationError] filter) can't match it. The call_tool docstring already advertises ValidationError: If arguments fail validation, so the implementation had drifted from the contract.

This wraps the caught PydanticValidationError in fastmcp.exceptions.ValidationError with from e, preserving the structured pydantic error as __cause__ so consumers that still want .errors() can walk the chain. The warning log just above the re-raise is unchanged, and the client-side wire contract (a ToolError reaches the client) is preserved.

The 10 existing pytest.raises(ValidationError) blocks in tests/server/providers/local_provider_tools/test_parameters.py that target mcp.call_tool(...) are migrated from pydantic.ValidationError to fastmcp.exceptions.ValidationError in the same PR, since they assert against the new error type. No TypeAdapter-direct tests exist in that file, so no pydantic.ValidationError raises are left intact.

The reporter notes in the issue thread that tool bodies can also raise pydantic.ValidationError from their own bugs, and those should arguably stay distinguishable from bad-call errors. Distinguishing the two requires splitting argument validation from invocation inside FunctionTool.run (today type_adapter.validate_python(arguments) does both), which is a deeper refactor than this bug warrants.

Closes #4128.

Contribution type

  • Bug fix (simple, well-scoped fix for a clearly broken behavior)
  • Documentation improvement
  • Enhancement (maintainers typically implement enhancements — see CONTRIBUTING.md)

Checklist

  • This PR addresses an existing issue (or fixes a self-evident bug)
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. labels May 17, 2026
@marvin-context-protocol

marvin-context-protocol Bot commented May 17, 2026 •

Copy link
Copy Markdown
Contributor

Edited to reflect the latest CI run — the original ruff issue is fixed; new failures are test expectations that weren't migrated alongside the behavior change.

tl;dr: Three tests still expect pydantic.ValidationError from mcp.call_tool(...), but this PR now wraps that as fastmcp.exceptions.ValidationError. Update the three pytest.raises(pydantic.ValidationError) blocks listed below to match.

Root Cause: The PR's whole point is to wrap pydantic's ValidationError from call_tool arg validation in fastmcp.exceptions.ValidationError (see fastmcp_slim/fastmcp/server/server.py:1288 — raise ValidationError(str(e)) from e). The PR description says the 10 raises in tests/server/providers/local_provider_tools/test_parameters.py were migrated, but three other call sites that also call mcp.call_tool(...) inside pytest.raises(pydantic.ValidationError) were missed and now fail because the exception they see is the wrapped fastmcp.exceptions.ValidationError, not the bare pydantic one.

Fix: In each of the three locations, replace pydantic.ValidationError with fastmcp.exceptions.ValidationError (the test file already imports pytest/pydantic; either import fastmcp.exceptions.ValidationError or assert on the wrapped error). The original pydantic error is preserved via __cause__, so existing message-matching like match="title" should still work via the wrapped str(e).

  • tests/apps/test_form.py:129 — pytest.raises(pydantic.ValidationError, match="title") around await server.call_tool(...).
  • tests/server/test_dependencies.py:456 — pytest.raises(pydantic.ValidationError) around await mcp.call_tool("validated_tool", {"age": "not a number"}).
  • tests/server/test_dependencies.py:604 — pytest.raises(pydantic.ValidationError) around await mcp.call_tool("check_permission", {"action": "read", "admin": "hacker"}).

All three failing jobs (Tests with lowest-direct dependencies, Tests: Python 3.10 on ubuntu-latest, Tests: Python 3.10 on windows-latest, Tests: Python 3.13 on ubuntu-latest) hit the same three tests.

Log excerpts

From Tests with lowest-direct dependencies:

FAILED tests/apps/test_form.py::TestFormInputProvider::test_submit_no_data_does_not_crash
  - fastmcp.exceptions.ValidationError: 2 validation errors for NoteForm
    title  Field required [type=missing, input_value={'archived': False}, input_type=dict]
    content  Field required [type=missing, input_value={'archived': False}, input_type=dict]

FAILED tests/server/test_dependencies.py::test_argument_validation_with_dependencies
  - fastmcp.exceptions.ValidationError: 1 validation error for call[validated_tool]
    age  Input should be a valid integer, unable to parse string as an integer
         [type=int_parsing, input_value='not a number', input_type=str]

FAILED tests/server/test_dependencies.py::test_external_user_cannot_override_dependency
  - fastmcp.exceptions.ValidationError: 1 validation error for call[check_permission]
    admin  Unexpected keyword argument [type=unexpected_keyword_argument, input_value='hacker', input_type=str]

======= 3 failed, 5673 passed, 2 skipped, 1 xfailed in 153.46s =======
Why this happened

The PR description correctly identifies tests/server/providers/local_provider_tools/test_parameters.py as the file with pytest.raises(ValidationError) blocks against mcp.call_tool(...) — but there are other such call sites in the repo. A repo-wide grep for pytest.raises(pydantic.ValidationError) near await ... call_tool would have surfaced these three.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c9b7295bf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

e.errors(include_url=False),
)
raise
raise ValidationError(str(e)) from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Don't classify tool-body validation failures as bad input

When a tool itself raises a pydantic.ValidationError (for example by constructing a Pydantic model from bad upstream data), that exception also exits tool._run() through the same PydanticValidationError handler because FunctionTool.run() performs argument validation and invokes the function inside one TypeAdapter.validate_python(...) call. Wrapping every such error as fastmcp.exceptions.ValidationError means middleware or Sentry filters intended to ignore caller argument mistakes will now also suppress real tool-body bugs. Please only wrap validation errors known to come from argument binding, or leave body-raised Pydantic errors on the normal tool-error path.

Useful? React with 👍 / 👎.

@strawgate

Copy link
Copy Markdown
Collaborator

Please follow the contributing guide and do not raise PRs without discussion on the issue first.

@strawgate strawgate closed this May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pydantic.ValidationError from argument validation should be wrapped as fastmcp's ValidationError before reaching middleware

2 participants