This is an automated email from the ASF dual-hosted git repository.

richardfogaca pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 51e708da6bb fix(mcp): accept Superset vocabulary in chart configs and 
clarify query_dataset metric errors (#40972)
51e708da6bb is described below

commit 51e708da6bb52ae4838613e5c3a1d851d725f0dc
Author: Richard Fogaca Nienkotter 
<[email protected]>
AuthorDate: Mon Jul 6 16:01:14 2026 -0300

    fix(mcp): accept Superset vocabulary in chart configs and clarify 
query_dataset metric errors (#40972)
---
 superset/mcp_service/chart/schemas.py              |  99 +++++++-
 superset/mcp_service/dataset/tool/query_dataset.py |  46 +++-
 .../chart/test_chart_config_coercion.py            | 281 +++++++++++++++++++++
 3 files changed, 415 insertions(+), 11 deletions(-)

diff --git a/superset/mcp_service/chart/schemas.py 
b/superset/mcp_service/chart/schemas.py
index dc9414e08c8..469e28ba1f8 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -1678,7 +1678,9 @@ class XYChartConfig(UnknownFieldCheckMixin):
         validation_alias=AliasChoices("time_grain", "time_grain_sqla"),
     )
     orientation: Literal["vertical", "horizontal"] | None = Field(
-        None, description="Bar orientation (only for kind='bar')"
+        None,
+        description="Bar orientation (only for kind='bar')",
+        validation_alias=AliasChoices("orientation", "chart_orientation"),
     )
     stacked: bool = Field(
         False,
@@ -1693,7 +1695,10 @@ class XYChartConfig(UnknownFieldCheckMixin):
     )
     x_axis: AxisConfig | None = None
     y_axis: AxisConfig | None = None
-    legend: LegendConfig | None = None
+    legend: LegendConfig | None = Field(
+        None,
+        validation_alias=AliasChoices("legend", "show_legend"),
+    )
     x_axis_time_format: str | None = Field(
         None,
         description=(
@@ -1738,6 +1743,24 @@ class XYChartConfig(UnknownFieldCheckMixin):
     def wrap_single_group_by(cls, v: Any) -> Any:
         return _normalize_group_by_input(v)
 
+    @field_validator("x", mode="before")
+    @classmethod
+    def coerce_x_column_name(cls, v: Any) -> Any:
+        """Accept a bare column name string for the x-axis."""
+        return {"name": v} if isinstance(v, str) else v
+
+    @field_validator("y", mode="before")
+    @classmethod
+    def coerce_y_column_names(cls, v: Any) -> Any:
+        """Accept bare strings or a single entry for the y-axis metrics."""
+        return _normalize_group_by_input(v)
+
+    @field_validator("legend", mode="before")
+    @classmethod
+    def coerce_legend_flag(cls, v: Any) -> Any:
+        """Accept the form_data-style show_legend boolean."""
+        return {"show": v} if isinstance(v, bool) else v
+
     @model_validator(mode="after")
     def reject_sql_expression_on_dimensions(self) -> "XYChartConfig":
         """sql_expression is metric-only; reject it on x and group_by."""
@@ -1825,6 +1848,70 @@ ChartConfig = Annotated[
 # giving LLMs enough context to construct valid configs.
 
 
+# Superset viz_type values that LLM clients routinely send where this API
+# expects its chart_type discriminator. Extend this observed-pattern map when
+# recurring session traces show additional unambiguous aliases. Each maps to
+# (chart_type, kind); kind is only meaningful for the xy family.
+_VIZ_TYPE_TO_CHART_TYPE: dict[str, tuple[str, str | None]] = {
+    "bar": ("xy", "bar"),
+    "dist_bar": ("xy", "bar"),
+    "echarts_timeseries_bar": ("xy", "bar"),
+    "line": ("xy", "line"),
+    "echarts_timeseries_line": ("xy", "line"),
+    "echarts_timeseries_smooth": ("xy", "line"),
+    "area": ("xy", "area"),
+    "echarts_area": ("xy", "area"),
+    "scatter": ("xy", "scatter"),
+    "echarts_timeseries_scatter": ("xy", "scatter"),
+    "ag-grid-table": ("table", None),
+    "big_number_total": ("big_number", None),
+    "pivot_table_v2": ("pivot_table", None),
+}
+
+
+def _normalize_chart_request_input(data: Any) -> Any:
+    """Accept common Superset REST/form_data vocabulary in chart requests.
+
+    LLM clients reliably reach for Superset's public field names —
+    ``datasource_id``, ``viz_type``, and concrete viz plugin names such as
+    ``echarts_timeseries_bar`` — before consulting this API's schema. Each
+    rejection costs the client a model round trip, so the unambiguous
+    synonyms are translated instead of refused.
+    """
+    if not isinstance(data, dict):
+        return data
+    if "dataset_id" not in data and "datasource_id" in data:
+        data["dataset_id"] = data.pop("datasource_id")
+    config = data.get("config")
+    if isinstance(config, dict):
+        viz_type = config.get("viz_type")
+        if isinstance(viz_type, str) and "chart_type" not in config:
+            config["chart_type"] = viz_type
+        chart_type = config.get("chart_type")
+        if isinstance(chart_type, str) and chart_type in 
_VIZ_TYPE_TO_CHART_TYPE:
+            mapped_type, kind = _VIZ_TYPE_TO_CHART_TYPE[chart_type]
+            config["chart_type"] = mapped_type
+            if kind is not None:
+                config.setdefault("kind", kind)
+            if mapped_type == "table":
+                config.setdefault("viz_type", chart_type)
+            else:
+                config.pop("viz_type", None)
+        elif config.get("chart_type") != "table":
+            config.pop("viz_type", None)
+    return data
+
+
+class ChartRequestNormalizerMixin(BaseModel):
+    """Mixin translating Superset-vocabulary synonyms before validation."""
+
+    @model_validator(mode="before")
+    @classmethod
+    def _normalize_request_vocabulary(cls, data: Any) -> Any:
+        """Normalize Superset-style request keys before Pydantic validation."""
+        return _normalize_chart_request_input(data)
+
+
 class ListChartsRequest(OwnedByMeMixin, CreatedByMeMixin, 
MetadataCacheControl):
     """Request schema for list_charts with clear, unambiguous types."""
 
@@ -1919,7 +2006,7 @@ class ListChartsRequest(OwnedByMeMixin, CreatedByMeMixin, 
MetadataCacheControl):
 
 
 # The tool input models
-class GenerateChartRequest(QueryCacheControl):
+class GenerateChartRequest(ChartRequestNormalizerMixin, QueryCacheControl):
     model_config = ConfigDict(populate_by_name=True)
 
     dataset_id: int | str = Field(..., description="Dataset identifier (ID, 
UUID)")
@@ -2019,7 +2106,7 @@ class GenerateChartRequest(QueryCacheControl):
         return self
 
 
-class GenerateExploreLinkRequest(FormDataCacheControl):
+class GenerateExploreLinkRequest(ChartRequestNormalizerMixin, 
FormDataCacheControl):
     dataset_id: int | str = Field(..., description="Dataset identifier (ID, 
UUID)")
     config: ChartConfig | None = Field(
         None,
@@ -2031,7 +2118,7 @@ class GenerateExploreLinkRequest(FormDataCacheControl):
     )
 
 
-class UpdateChartRequest(QueryCacheControl):
+class UpdateChartRequest(ChartRequestNormalizerMixin, QueryCacheControl):
     model_config = ConfigDict(populate_by_name=True)
 
     identifier: int | str = Field(..., description="Chart ID or UUID")
@@ -2078,7 +2165,7 @@ class UpdateChartRequest(QueryCacheControl):
         return sanitize_user_input(v, "Chart name", max_length=255, 
allow_empty=True)
 
 
-class UpdateChartPreviewRequest(FormDataCacheControl):
+class UpdateChartPreviewRequest(ChartRequestNormalizerMixin, 
FormDataCacheControl):
     form_data_key: str | None = Field(
         None,
         description=(
diff --git a/superset/mcp_service/dataset/tool/query_dataset.py 
b/superset/mcp_service/dataset/tool/query_dataset.py
index da7f2d227f6..c87c7a0945e 100644
--- a/superset/mcp_service/dataset/tool/query_dataset.py
+++ b/superset/mcp_service/dataset/tool/query_dataset.py
@@ -84,22 +84,48 @@ def _validate_names(
     requested: list[str],
     valid: set[str],
     kind: str,
+    *,
+    empty_hint: str | None = None,
+    list_valid_on_miss: bool = False,
 ) -> list[str]:
     """Return list of error messages for names not found in *valid*.
 
-    Includes close-match suggestions when available.
+    Includes close-match suggestions when available. When *valid* is empty,
+    appends *empty_hint* instead of a useless fuzzy match. When no close
+    match exists and *list_valid_on_miss* is set, lists the valid names so
+    the caller does not have to guess again.
     """
     errors: list[str] = []
     for name in requested:
         if name not in valid:
-            suggestions = difflib.get_close_matches(name, valid, n=3, 
cutoff=0.6)
             msg = f"Unknown {kind}: '{name}'"
-            if suggestions:
-                msg += f". Did you mean: {', '.join(suggestions)}?"
+            if not valid:
+                if empty_hint:
+                    msg += f". {empty_hint}"
+            else:
+                suggestions = difflib.get_close_matches(name, valid, n=3, 
cutoff=0.6)
+                if suggestions:
+                    msg += f". Did you mean: {', '.join(suggestions)}?"
+                elif list_valid_on_miss:
+                    shown = sorted(valid)[:10]
+                    more = len(valid) - len(shown)
+                    suffix = (
+                        f" (and {more} more; call get_dataset_info for the 
full list)"
+                        if more > 0
+                        else ""
+                    )
+                    msg += f". Valid {kind}s: {', '.join(shown)}{suffix}"
             errors.append(msg)
     return errors
 
 
+_NO_SAVED_METRICS_HINT = (
+    "This dataset has no saved metrics, and query_dataset accepts saved "
+    "metric names only (not ad-hoc expressions). Use execute_sql for "
+    "ad-hoc aggregates, or add a saved metric to the dataset."
+)
+
+
 @requires_data_model_metadata_access
 @tool(
     tags=["data"],
@@ -119,6 +145,10 @@ async def query_dataset(  # noqa: C901
     to compute saved metrics, group by dimensions, or apply filters directly
     against a dataset's curated semantic layer.
 
+    Metrics must be saved metric names from get_dataset_info; ad-hoc
+    expressions such as "SUM(col)" are not accepted. When the dataset has no
+    saved metric for the aggregate you need, use execute_sql instead.
+
     Workflow:
     1. list_datasets -> find a dataset
     2. get_dataset_info -> discover available columns and metrics
@@ -213,7 +243,13 @@ async def query_dataset(  # noqa: C901
             _validate_names(request.columns, valid_columns, "column")
         )
         validation_errors.extend(
-            _validate_names(request.metrics, valid_metrics, "metric")
+            _validate_names(
+                request.metrics,
+                valid_metrics,
+                "metric",
+                empty_hint=_NO_SAVED_METRICS_HINT,
+                list_valid_on_miss=True,
+            )
         )
         # Validate filter column names against dataset columns
         filter_cols = [f.col for f in request.filters]
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_config_coercion.py 
b/tests/unit_tests/mcp_service/chart/test_chart_config_coercion.py
new file mode 100644
index 00000000000..bc1c8696b9c
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_chart_config_coercion.py
@@ -0,0 +1,281 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Unit tests for Superset-vocabulary coercion in MCP chart request schemas.
+
+LLM clients reliably send Superset's public field names (datasource_id,
+viz_type, show_legend, plugin viz names) before consulting the MCP tool
+schema. These tests pin the translations that absorb those inputs instead
+of rejecting them.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.schemas import (
+    GenerateChartRequest,
+    GenerateExploreLinkRequest,
+    TableChartConfig,
+    UpdateChartPreviewRequest,
+    UpdateChartRequest,
+    XYChartConfig,
+)
+from superset.mcp_service.dataset.tool.query_dataset import (
+    _NO_SAVED_METRICS_HINT,
+    _validate_names,
+)
+
+XY_BAR_CONFIG = {
+    "chart_type": "xy",
+    "kind": "bar",
+    "x": {"name": "genre"},
+    "y": [{"name": "global_sales", "aggregate": "SUM"}],
+}
+
+
+class TestDatasourceIdAlias:
+    """Datasource alias normalization for chart request models."""
+
+    def test_generate_chart_accepts_datasource_id(self) -> None:
+        request = GenerateChartRequest.model_validate(
+            {"datasource_id": 23, "config": dict(XY_BAR_CONFIG)}
+        )
+        assert request.dataset_id == 23
+
+    def test_dataset_id_wins_over_datasource_id(self) -> None:
+        request = GenerateChartRequest.model_validate(
+            {"dataset_id": 7, "datasource_id": 23, "config": 
dict(XY_BAR_CONFIG)}
+        )
+        assert request.dataset_id == 7
+
+
+class TestVizTypeTranslation:
+    """Superset viz plugin names map to MCP chart config discriminators."""
+
+    @pytest.mark.parametrize(
+        "viz_type,expected_kind",
+        [
+            ("bar", "bar"),
+            ("dist_bar", "bar"),
+            ("echarts_timeseries_bar", "bar"),
+            ("echarts_timeseries_line", "line"),
+            ("area", "area"),
+            ("echarts_timeseries_scatter", "scatter"),
+        ],
+    )
+    def test_xy_family_viz_types_map_to_xy(
+        self, viz_type: str, expected_kind: str
+    ) -> None:
+        config = dict(XY_BAR_CONFIG)
+        del config["kind"]
+        config["chart_type"] = viz_type
+        request = GenerateChartRequest.model_validate(
+            {"dataset_id": 23, "config": config}
+        )
+        assert request.config.chart_type == "xy"
+        assert request.config.kind == expected_kind
+
+    def test_viz_type_key_is_accepted_as_chart_type(self) -> None:
+        config = dict(XY_BAR_CONFIG)
+        del config["chart_type"]
+        del config["kind"]
+        config["viz_type"] = "bar"
+        request = GenerateChartRequest.model_validate(
+            {"dataset_id": 23, "config": config}
+        )
+        assert request.config.chart_type == "xy"
+        assert request.config.kind == "bar"
+
+    def test_explicit_chart_type_wins_over_viz_type(self) -> None:
+        config = dict(XY_BAR_CONFIG)
+        config["viz_type"] = "pie"
+        request = GenerateChartRequest.model_validate(
+            {"dataset_id": 23, "config": config}
+        )
+        assert request.config.chart_type == "xy"
+
+    def test_table_viz_type_is_preserved_with_explicit_chart_type(self) -> 
None:
+        request = GenerateExploreLinkRequest.model_validate(
+            {
+                "dataset_id": 23,
+                "config": {
+                    "chart_type": "table",
+                    "viz_type": "ag-grid-table",
+                    "columns": [{"name": "genre"}],
+                },
+            }
+        )
+        assert isinstance(request.config, TableChartConfig)
+        assert request.config.viz_type == "ag-grid-table"
+
+    def test_table_viz_type_key_maps_to_table_config(self) -> None:
+        request = GenerateExploreLinkRequest.model_validate(
+            {
+                "dataset_id": 23,
+                "config": {
+                    "viz_type": "ag-grid-table",
+                    "columns": [{"name": "genre"}],
+                },
+            }
+        )
+        assert isinstance(request.config, TableChartConfig)
+        assert request.config.chart_type == "table"
+        assert request.config.viz_type == "ag-grid-table"
+
+    def test_explicit_invalid_chart_type_wins_over_viz_type(self) -> None:
+        with pytest.raises(ValidationError, match="xyy"):
+            GenerateChartRequest.model_validate(
+                {
+                    "dataset_id": 23,
+                    "config": {
+                        **XY_BAR_CONFIG,
+                        "chart_type": "xyy",
+                        "viz_type": "echarts_timeseries_bar",
+                    },
+                }
+            )
+
+    def test_explicit_kind_is_preserved(self) -> None:
+        config = dict(XY_BAR_CONFIG)
+        config["chart_type"] = "echarts_timeseries_bar"
+        config["kind"] = "line"
+        request = GenerateChartRequest.model_validate(
+            {"dataset_id": 23, "config": config}
+        )
+        assert request.config.kind == "line"
+
+    def test_big_number_total_maps_to_big_number(self) -> None:
+        request = GenerateChartRequest.model_validate(
+            {
+                "dataset_id": 23,
+                "config": {
+                    "chart_type": "big_number_total",
+                    "metric": {"name": "global_sales", "aggregate": "SUM"},
+                },
+            }
+        )
+        assert request.config.chart_type == "big_number"
+
+
+class TestRequestModelsShareNormalization:
+    """Request models share chart vocabulary normalization behavior."""
+
+    def test_update_chart_request(self) -> None:
+        request = UpdateChartRequest.model_validate(
+            {"identifier": 409, "config": {**XY_BAR_CONFIG, "chart_type": 
"bar"}}
+        )
+        assert request.config is not None
+        assert request.config.chart_type == "xy"
+
+    def test_update_chart_preview_request(self) -> None:
+        request = UpdateChartPreviewRequest.model_validate(
+            {"datasource_id": 23, "config": {**XY_BAR_CONFIG, "chart_type": 
"bar"}}
+        )
+        assert request.dataset_id == 23
+        assert request.config.chart_type == "xy"
+
+    def test_generate_explore_link_request(self) -> None:
+        request = GenerateExploreLinkRequest.model_validate(
+            {"datasource_id": 23, "config": {**XY_BAR_CONFIG, "chart_type": 
"bar"}}
+        )
+        assert request.dataset_id == 23
+        assert request.config is not None
+        assert request.config.chart_type == "xy"
+
+
+class TestXYFieldCoercion:
+    """XY chart config accepts common Superset form-data field shapes."""
+
+    def test_x_accepts_bare_column_name(self) -> None:
+        config = XYChartConfig.model_validate({**XY_BAR_CONFIG, "x": "genre"})
+        assert config.x is not None
+        assert config.x.name == "genre"
+
+    def test_y_accepts_bare_metric_names(self) -> None:
+        config = XYChartConfig.model_validate({**XY_BAR_CONFIG, "y": 
["count"]})
+        assert config.y[0].name == "count"
+
+    def test_show_legend_bool_becomes_legend_config(self) -> None:
+        config = XYChartConfig.model_validate({**XY_BAR_CONFIG, "show_legend": 
True})
+        assert config.legend is not None
+        assert config.legend.show is True
+
+    def test_show_legend_false(self) -> None:
+        config = XYChartConfig.model_validate({**XY_BAR_CONFIG, "show_legend": 
False})
+        assert config.legend is not None
+        assert config.legend.show is False
+
+    def test_chart_orientation_alias(self) -> None:
+        config = XYChartConfig.model_validate(
+            {**XY_BAR_CONFIG, "chart_orientation": "horizontal"}
+        )
+        assert config.orientation == "horizontal"
+
+    def test_unknown_fields_still_rejected_with_suggestion(self) -> None:
+        with pytest.raises(ValidationError, match="order_desc"):
+            XYChartConfig.model_validate({**XY_BAR_CONFIG, "order_desc": True})
+
+
+class TestQueryDatasetMetricGuidance:
+    """Metric validation errors guide callers toward saved metric names."""
+
+    def test_no_saved_metrics_hint(self) -> None:
+        errors = _validate_names(
+            ["sum__global_sales"],
+            set(),
+            "metric",
+            empty_hint=_NO_SAVED_METRICS_HINT,
+        )
+        assert len(errors) == 1
+        assert _NO_SAVED_METRICS_HINT in errors[0]
+        assert "Did you mean" not in errors[0]
+
+    def test_valid_metrics_listed_when_no_close_match(self) -> None:
+        errors = _validate_names(
+            ["zzz_nonexistent"],
+            {"count", "revenue_total"},
+            "metric",
+            list_valid_on_miss=True,
+        )
+        assert len(errors) == 1
+        assert "Valid metrics: count, revenue_total" in errors[0]
+
+    def test_truncated_valid_metrics_report_remaining_count(self) -> None:
+        valid_metrics = {f"metric_{idx:02d}" for idx in range(12)}
+
+        errors = _validate_names(
+            ["zzz_nonexistent"],
+            valid_metrics,
+            "metric",
+            list_valid_on_miss=True,
+        )
+
+        assert len(errors) == 1
+        assert "metric_00, metric_01" in errors[0]
+        assert "metric_10" not in errors[0]
+        assert "and 2 more; call get_dataset_info for the full list" in 
errors[0]
+
+    def test_close_match_suggestion_unchanged(self) -> None:
+        errors = _validate_names(
+            ["revenue_totl"],
+            {"count", "revenue_total"},
+            "metric",
+            list_valid_on_miss=True,
+        )
+        assert len(errors) == 1
+        assert "Did you mean: revenue_total?" in errors[0]

Reply via email to