diff --git a/superset/mcp_service/utils/schema_utils.py b/superset/mcp_service/utils/schema_utils.py index f78ad20f050..4f2a588cce9 100644 --- a/superset/mcp_service/utils/schema_utils.py +++ b/superset/mcp_service/utils/schema_utils.py @@ -390,8 +390,8 @@ def parse_request( Decorator to handle Claude Code bug where requests are double-serialized as strings. Automatically parses string requests to Pydantic models before calling - the tool function. - This eliminates the need for manual parsing code in every tool function. + the tool function. Also modifies the function's type annotations to accept + str | RequestModel to pass FastMCP validation. See: https://github.com/anthropics/claude-code/issues/5504 @@ -406,15 +406,17 @@ def parse_request( @mcp_auth_hook @parse_request(ListChartsRequest) async def list_charts( - request: ListChartsRequest, ctx: Context + request: ListChartsRequest, ctx: Context # Keep clean type hint ) -> ChartList: - # Decorator handles string conversion automatically + # Decorator handles string conversion and type annotation await ctx.info(f"Listing charts: page={request.page}") ... Note: - Works with both async and sync functions - Request must be the first positional argument + - Modifies __annotations__ to accept str | RequestModel for FastMCP + - Function implementation can use clean RequestModel type hint - If request is already a model instance, it passes through unchanged - Handles JSON string parsing with helpful error messages """ @@ -429,7 +431,7 @@ def parse_request( parsed_request = parse_json_or_model(request, request_class, "request") return await func(parsed_request, *args, **kwargs) - return async_wrapper + wrapper = async_wrapper else: @wraps(func) @@ -439,6 +441,15 @@ def parse_request( parsed_request = parse_json_or_model(request, request_class, "request") return func(parsed_request, *args, **kwargs) - return sync_wrapper + wrapper = sync_wrapper + + # Modify the wrapper's annotations to accept str | RequestModel + # This allows FastMCP to accept string inputs while keeping the + # original function's type hints clean + if hasattr(wrapper, "__annotations__"): + # Create union type: str | RequestModel + wrapper.__annotations__["request"] = str | request_class + + return wrapper return decorator diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py index d9b1c3f53bd..548c546930e 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py @@ -191,9 +191,12 @@ async def test_list_dashboards_with_string_filters(mock_list, mcp_server): async with Client(mcp_server) as client: # noqa: F841 filters = '[{"col": "dashboard_title", "opr": "sw", "value": "Sales"}]' - # Test that string filters cause validation error at schema level - with pytest.raises(ValueError, match="validation error"): - ListDashboardsRequest(filters=filters) # noqa: F841 + # Test that string filters are now properly parsed to objects + request = ListDashboardsRequest(filters=filters) + assert len(request.filters) == 1 + assert request.filters[0].col == "dashboard_title" + assert request.filters[0].opr == "sw" + assert request.filters[0].value == "Sales" @patch("superset.daos.dashboard.DashboardDAO.list")