Skip to content

Commit dbcccb3

Browse files
fix: catch fastmcp 3.4.3's wrapped ValidationError in arg-validation middleware (#1757)
fastmcp 3.4.3 (tools/function_tool.py) re-raises an argument-validation pydantic.ValidationError as fastmcp.exceptions.ValidationError, chained via `from e`. ValidationErrorMiddleware only caught pydantic.ValidationError, so under 3.4.3 the raw wrapped error propagated instead of ha-mcp's structured, actionable ToolError (3 tests in test_validation_middleware.py failed once fastmcp was bumped in #1753). Catch both exception shapes and recover the pydantic errors from the FastMCPValidationError's __cause__; re-raise any fastmcp ValidationError with no pydantic cause (e.g. a return-value failure) unchanged. Cross-version safe: a bare pydantic.ValidationError on older fastmcp is still handled directly. Adds a version-independent regression test that synthesises the wrapped error so the 3.4.3 path is exercised on the current (3.4.2) CI, not only once #1753 lands. Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8284017 commit dbcccb3

2 files changed

Lines changed: 54 additions & 6 deletions

File tree

src/ha_mcp/tools/validation_middleware.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
"""FastMCP middleware that converts Pydantic validation errors to structured ToolErrors.
22
33
When a model passes the wrong type for a tool parameter (e.g. a JSON string where
4-
a dict is required), FastMCP raises a PydanticValidationError with a raw message
5-
like "Input should be a valid dictionary". This middleware intercepts those errors
6-
and converts them to ha-mcp's structured format with actionable guidance.
4+
a dict is required), FastMCP surfaces a validation error with a raw message like
5+
"Input should be a valid dictionary" -- a bare ``pydantic.ValidationError`` on
6+
older FastMCP, or a ``fastmcp.exceptions.ValidationError`` wrapping it (chained
7+
via ``from e``) on FastMCP >= 3.4.3. This middleware intercepts either shape and
8+
converts it to ha-mcp's structured format with actionable guidance.
79
"""
810

911
from __future__ import annotations
1012

1113
import logging
1214
from typing import Any
1315

16+
from fastmcp.exceptions import ValidationError as FastMCPValidationError
1417
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
1518
from pydantic import ValidationError as PydanticValidationError
1619

@@ -40,9 +43,20 @@ async def on_call_tool(
4043
self, context: MiddlewareContext, call_next: CallNext
4144
) -> Any:
4245
try:
43-
return await call_next(context)
44-
except PydanticValidationError as exc:
45-
errors = exc.errors(include_url=False)
46+
result = await call_next(context)
47+
except (PydanticValidationError, FastMCPValidationError) as exc:
48+
# fastmcp >= 3.4.3 re-raises an argument-validation failure as
49+
# ``fastmcp.exceptions.ValidationError`` wrapping the pydantic error
50+
# (chained via ``from e``); older fastmcp raises the pydantic error
51+
# directly. Recover the pydantic errors from whichever shape arrived,
52+
# and let any other fastmcp ValidationError (e.g. a return-value
53+
# failure with no pydantic cause) propagate unchanged.
54+
pydantic_exc = (
55+
exc if isinstance(exc, PydanticValidationError) else exc.__cause__
56+
)
57+
if not isinstance(pydantic_exc, PydanticValidationError):
58+
raise
59+
errors = pydantic_exc.errors(include_url=False)
4660
# Group by the real argument path. A union param like
4761
# `str | list[str]` emits one error per arm with loc (param, "str"),
4862
# (param, "list[str]"); without grouping the user saw `param.str` /
@@ -73,3 +87,4 @@ async def on_call_tool(
7387
details=", ".join(dict.fromkeys(err["type"] for err in errors)),
7488
)
7589
)
90+
return result

tests/src/unit/test_validation_middleware.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,39 @@ async def ha_test_two_params(config: dict, items: list) -> dict:
3232
return mcp
3333

3434

35+
@pytest.mark.asyncio
36+
async def test_wrapped_fastmcp_validation_error_is_structured():
37+
"""fastmcp >= 3.4.3 wraps an arg-validation pydantic error in
38+
``fastmcp.exceptions.ValidationError`` (chained via ``from e``); the
39+
middleware must recover the pydantic cause and still emit a structured
40+
ToolError. Version-independent: the wrapped error is synthesised, so this
41+
exercises the 3.4.3 code path even on older fastmcp.
42+
"""
43+
from fastmcp.exceptions import ValidationError as FastMCPValidationError
44+
from pydantic import BaseModel
45+
from pydantic import ValidationError as PydanticValidationError
46+
47+
class _Args(BaseModel):
48+
config: dict
49+
50+
with pytest.raises(PydanticValidationError) as pyd_info:
51+
_Args(config="{}") # str where a dict is required -> dict_type error
52+
53+
wrapped = FastMCPValidationError(str(pyd_info.value))
54+
wrapped.__cause__ = pyd_info.value
55+
56+
async def _raise_wrapped(_context):
57+
raise wrapped
58+
59+
middleware = ValidationErrorMiddleware()
60+
with pytest.raises(ToolError) as exc_info:
61+
await middleware.on_call_tool(None, _raise_wrapped)
62+
63+
msg = json.loads(str(exc_info.value))["error"]["message"]
64+
assert "config" in msg
65+
assert "JSON object" in msg
66+
67+
3568
@pytest.mark.asyncio
3669
async def test_string_for_dict_gives_actionable_message():
3770
"""Passing a JSON string where a dict is expected raises a structured ToolError."""

0 commit comments

Comments
 (0)