You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/09/09 14:34:57 UTC

(superset) branch master updated: fix(mcp): complete Gauge chart support follow-up (#43943)

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

aminghadersohi 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 08e686b5353 fix(mcp): complete Gauge chart support follow-up (#43943)
08e686b5353 is described below

commit 08e686b5353eb11f9c0fbe04cc05c7ff508d823f
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Sep 9 10:34:50 2026 -0400

    fix(mcp): complete Gauge chart support follow-up (#43943)
---
 docs/admin_docs/configuration/mcp-server.mdx       |   6 +-
 superset/mcp_service/app.py                        |   2 +
 superset/mcp_service/chart/chart_utils.py          | 182 ++++
 superset/mcp_service/chart/compile.py              | 105 ++-
 superset/mcp_service/chart/plugins/gauge.py        |  75 +-
 superset/mcp_service/chart/preview_utils.py        | 537 ++++++++++-
 superset/mcp_service/chart/query_result.py         | 225 +++++
 .../mcp_service/chart/resources/chart_configs.py   |  24 +
 superset/mcp_service/chart/schemas.py              | 250 +++++-
 superset/mcp_service/chart/tool/generate_chart.py  |   6 +-
 superset/mcp_service/chart/tool/get_chart_data.py  |  52 +-
 .../mcp_service/chart/tool/get_chart_preview.py    |  95 +-
 .../chart/tool/get_chart_type_schema.py            |  10 +-
 superset/mcp_service/chart/tool/update_chart.py    |  96 +-
 .../mcp_service/chart/tool/update_chart_preview.py |  92 +-
 .../chart/validation/dataset_validator.py          | 208 +++--
 .../chart/validation/schema_validator.py           |  11 +
 .../explore/tool/generate_explore_link.py          |   2 +-
 .../common/test_query_context_processor.py         |  25 +-
 tests/unit_tests/connectors/sqla/models_test.py    |  48 +
 tests/unit_tests/mcp_service/chart/test_compile.py | 109 +++
 .../mcp_service/chart/test_gauge_chart.py          | 994 ++++++++++++++++++++-
 .../mcp_service/chart/test_gauge_render.py         | 106 +++
 .../mcp_service/chart/test_preview_utils.py        |  71 ++
 .../mcp_service/chart/tool/test_generate_chart.py  |  86 ++
 .../mcp_service/chart/tool/test_get_chart_data.py  | 281 +++++-
 .../chart/tool/test_get_chart_preview.py           | 152 ++++
 .../chart/tool/test_get_chart_type_schema.py       |  29 +
 .../mcp_service/chart/tool/test_update_chart.py    | 207 ++++-
 .../chart/tool/test_update_chart_preview.py        | 360 ++------
 .../explore/tool/test_generate_explore_link.py     | 123 ++-
 31 files changed, 3990 insertions(+), 579 deletions(-)

diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index 847961b6bae..d66c9270076 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -954,7 +954,11 @@ Specifying a tool name that does not exist logs a warning at startup and is othe
 
 ## Disabling chart type plugins
 
-The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `pivot_table`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
+The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime,  [...]
+
+Gauge data inspection and JSON/CSV/XLSX exports preserve source groups, including NULL and non-finite metric values, using the same row-count and completeness semantics as other chart types. The default XLSX exporter writes non-finite floats as `nan`, `inf`, or `-inf` text, matching CSV and distinguishing them from blank NULL cells. Only previews and compile checks skip NULL, NaN, non-finite, and other nonnumeric dial values while preserving finite groups. In preview/compile paths, a non [...]
+
+Gauge previews retain data-derived automatic bounds even when configured interval thresholds extend beyond the visible range; Vega previews clip those thresholds into adjacent, non-overlapping bands. Explicit min/max bounds still constrain intervals. All-zero data with both bounds omitted uses a nondegenerate preview range of 0–1. On same-dataset Gauge updates, clearing or changing the dashboard temporal binding preserves user-authored temporal ranges; dataset rebinds discard old query r [...]
 
 ### Static deny-list
 
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 145862681d4..d90ae7b03aa 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -395,6 +395,8 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
 - chart_type="table": Data table for detailed views
 - chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
 - chart_type="pie": Pie chart for proportional data (set donut=True for donut)
+- chart_type="gauge": Gauge/dial for one numeric metric, optionally grouped
+  into up to 10 dials (native viz_type is "gauge_chart")
 - chart_type="pivot_table": OSS Pivot Table for cross-tabulation
 - chart_type="interactive_pivot": Extension-provided AG Grid Interactive Pivot Table.
   This type is distinct from pivot_table/pivot_table_v2 and is available only
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index a59222f35bb..d8ad21141a5 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -599,6 +599,168 @@ def merge_interactive_pivot_ui_config(
         new_form_data["pivot_table_state"] = {**existing_state, **new_state}
 
 
+_GAUGE_FORM_DATA_FIELD_MAP: dict[str, str] = {
+    "groupby": "groupby",
+    "sort_by_metric": "sort_by_metric",
+    "row_limit": "row_limit",
+    "min_val": "min_val",
+    "max_val": "max_val",
+    "color_scheme": "color_scheme",
+    "font_size": "font_size",
+    "number_format": "number_format",
+    "currency_format": "currency_format",
+    "value_formatter": "value_formatter",
+    "start_angle": "start_angle",
+    "end_angle": "end_angle",
+    "show_pointer": "show_pointer",
+    "animation": "animation",
+    "show_axis_tick": "show_axis_tick",
+    "show_split_line": "show_split_line",
+    "split_number": "split_number",
+    "show_progress": "show_progress",
+    "overlap": "overlap",
+    "round_cap": "round_cap",
+    "intervals": "intervals",
+    "interval_color_indices": "interval_color_indices",
+    "time_range": "time_range",
+    "granularity_sqla": "granularity_sqla",
+}
+
+_GAUGE_PRESENTATION_FORM_DATA_KEYS = frozenset(
+    {
+        "min_val",
+        "max_val",
+        "color_scheme",
+        "font_size",
+        "number_format",
+        "currency_format",
+        "value_formatter",
+        "start_angle",
+        "end_angle",
+        "show_pointer",
+        "animation",
+        "show_axis_tick",
+        "show_split_line",
+        "split_number",
+        "show_progress",
+        "overlap",
+        "round_cap",
+        "intervals",
+        "interval_color_indices",
+    }
+)
+
+
+def _without_generated_gauge_time_filter(
+    form_data: dict[str, Any],
+) -> list[Any]:
+    """Return cached filters without the mapper-owned temporal binding."""
+    generated_subject = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    return [
+        filter_
+        for filter_ in form_data.get("adhoc_filters", [])
+        if not (
+            generated_subject
+            and isinstance(filter_, dict)
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+            and filter_.get("subject") == generated_subject
+            and filter_.get("comparator") == NO_TIME_RANGE
+            and filter_.get("clause") == "WHERE"
+            and filter_.get("expressionType") == "SIMPLE"
+        )
+    ]
+
+
+def merge_chart_form_data(  # noqa: C901
+    existing_form_data: dict[str, Any],
+    new_form_data: dict[str, Any],
+    config: ChartConfig,
+    *,
+    dataset_rebind: bool = False,
+) -> dict[str, Any]:
+    """Merge update form_data while preserving omitted same-viz controls.
+
+    A viz-type change never inherits old controls. Dataset rebinds similarly
+    drop query roles and filters; Gauge presentation controls remain safe to
+    preserve because they do not reference the old dataset.
+    """
+    if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
+        return dict(new_form_data)
+    if not isinstance(config, GaugeChartConfig):
+        if dataset_rebind:
+            return dict(new_form_data)
+        merged = {**existing_form_data, **new_form_data}
+        if getattr(config, "filters", None) == []:
+            merged.pop("adhoc_filters", None)
+        return merged
+
+    fields_set = config.model_fields_set
+    if dataset_rebind:
+        merged = {
+            key: value
+            for key, value in existing_form_data.items()
+            if key in _GAUGE_PRESENTATION_FORM_DATA_KEYS
+        }
+    else:
+        merged = dict(existing_form_data)
+
+    patch = dict(new_form_data)
+    for config_field, form_data_field in _GAUGE_FORM_DATA_FIELD_MAP.items():
+        if config_field not in fields_set:
+            patch.pop(form_data_field, None)
+
+    filters_explicit = "filters" in fields_set
+    temporal_explicit = "temporal_column" in fields_set
+    if not filters_explicit:
+        if temporal_explicit:
+            preserved_filters = (
+                []
+                if dataset_rebind
+                else _without_generated_gauge_time_filter(existing_form_data)
+            )
+            generated_filters = patch.get("adhoc_filters", [])
+            patch["adhoc_filters"] = [*preserved_filters, *generated_filters]
+            if config.temporal_column is None:
+                patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+        else:
+            patch.pop("adhoc_filters", None)
+            patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+    merged.update(patch)
+    if filters_explicit:
+        if config.filters == [] and not (temporal_explicit and config.temporal_column):
+            merged.pop("adhoc_filters", None)
+        if MCP_DASHBOARD_TIME_FILTER_SUBJECT not in patch:
+            merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+    for nullable_field in (
+        "color_scheme",
+        "currency_format",
+        "time_range",
+        "granularity_sqla",
+    ):
+        if nullable_field in fields_set and getattr(config, nullable_field) is None:
+            merged.pop(_GAUGE_FORM_DATA_FIELD_MAP[nullable_field], None)
+    if temporal_explicit and config.temporal_column is None:
+        merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+    if subject := merged.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT):
+        seen_binding = False
+        filters = []
+        for filter_ in merged.get("adhoc_filters", []):
+            is_binding = (
+                isinstance(filter_, dict)
+                and filter_.get("subject") == subject
+                and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+                and filter_.get("comparator") == NO_TIME_RANGE
+                and filter_.get("clause") == "WHERE"
+                and filter_.get("expressionType") == "SIMPLE"
+            )
+            if not is_binding or not seen_binding:
+                filters.append(filter_)
+            seen_binding = seen_binding or is_binding
+        merged["adhoc_filters"] = filters
+    return merged
+
+
 def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
     """Create a metric object for a column with enhanced validation.
 
@@ -1062,7 +1224,27 @@ def map_gauge_config(config: GaugeChartConfig) -> Dict[str, Any]:
         "min_val": config.min_val,
         "max_val": config.max_val,
         "color_scheme": config.color_scheme or "supersetColors",
+        "font_size": config.font_size,
+        "number_format": config.number_format,
+        "value_formatter": config.value_formatter,
+        "start_angle": config.start_angle,
+        "end_angle": config.end_angle,
+        "show_pointer": config.show_pointer,
+        "animation": config.animation,
+        "show_axis_tick": config.show_axis_tick,
+        "show_split_line": config.show_split_line,
+        "split_number": config.split_number,
+        "show_progress": config.show_progress,
+        "overlap": config.overlap,
+        "round_cap": config.round_cap,
+        "intervals": config.intervals,
+        "interval_color_indices": config.interval_color_indices,
     }
+    if config.time_range is not None:
+        form_data["time_range"] = config.time_range
+    if config.granularity_sqla is not None:
+        form_data["granularity_sqla"] = config.granularity_sqla
+    add_currency_format(form_data, config.currency_format)
     _add_adhoc_filters(form_data, config.filters)
     return form_data
 
diff --git a/superset/mcp_service/chart/compile.py b/superset/mcp_service/chart/compile.py
index 062be8ef6cb..f4f3e80728e 100644
--- a/superset/mcp_service/chart/compile.py
+++ b/superset/mcp_service/chart/compile.py
@@ -33,6 +33,7 @@ tier(s) appropriate for its SLA.
 from __future__ import annotations
 
 import logging
+from copy import deepcopy
 from dataclasses import dataclass, field
 from typing import Any, Dict, List, Literal
 
@@ -40,9 +41,16 @@ from sqlalchemy.exc import SQLAlchemyError
 
 from superset.commands.exceptions import CommandException
 from superset.errors import SupersetErrorType
+from superset.mcp_service.chart.query_result import (
+    normalize_gauge_query_result,
+    query_result_failure,
+)
+from superset.mcp_service.chart.schemas import ChartError
 from superset.mcp_service.chart.validation.dataset_validator import (
+    AmbiguousDatasetReferenceError,
     build_dataset_context_from_orm,
     DatasetValidator,
+    resolve_dataset_reference,
 )
 from superset.mcp_service.common.error_schemas import (
     ChartGenerationError,
@@ -116,39 +124,21 @@ def _compile_chart(
         ChartDataCacheLoadError,
         ChartDataQueryFailedError,
     )
-    from superset.common.query_context_factory import QueryContextFactory
-    from superset.mcp_service.chart.chart_utils import adhoc_filters_to_query_filters
-    from superset.mcp_service.chart.preview_utils import _build_query_columns
+    from superset.mcp_service.chart.chart_helpers import (
+        build_query_context_from_form_data,
+    )
 
     try:
-        columns = _build_query_columns(form_data)
-        query_filters = adhoc_filters_to_query_filters(
-            form_data.get("adhoc_filters", [])
-        )
-
-        # Big Number charts use singular "metric" instead of "metrics"
-        metrics = form_data.get("metrics", [])
-        if not metrics and form_data.get("metric"):
-            metrics = [form_data["metric"]]
-
-        # Big Number with trendline uses granularity_sqla as the time column
-        if not columns and form_data.get("granularity_sqla"):
-            columns = [form_data["granularity_sqla"]]
-
-        factory = QueryContextFactory()
-        query_context = factory.create(
-            datasource={"id": dataset_id, "type": "table"},
-            queries=[
-                {
-                    "columns": columns,
-                    "metrics": metrics,
-                    "orderby": form_data.get("orderby", []),
-                    "row_limit": 2,
-                    "filters": query_filters,
-                    "time_range": form_data.get("time_range", "No filter"),
-                }
-            ],
-            form_data=form_data,
+        query_form_data = deepcopy(form_data)
+        query_form_data["datasource"] = f"{dataset_id}__table"
+        query_form_data["datasource_id"] = dataset_id
+        query_form_data["datasource_type"] = "table"
+        query_context = build_query_context_from_form_data(
+            query_form_data,
+            row_limit=min(10, int(form_data.get("row_limit") or 10))
+            if form_data.get("viz_type") == "gauge_chart"
+            else 2,
+            force=False,
         )
 
         command = ChartDataCommand(query_context)
@@ -157,16 +147,34 @@ def _compile_chart(
 
         warnings: List[str] = []
         row_count = 0
+        if query_failure := query_result_failure(result):
+            error_str = query_failure.error
+            return CompileResult(
+                success=False,
+                error=error_str,
+                error_code="CHART_COMPILE_FAILED",
+                tier="compile",
+                error_obj=_build_compile_error(error_str),
+            )
+        result = normalize_gauge_query_result(result, form_data)
+        if isinstance(result, ChartError):
+            return CompileResult(
+                success=False,
+                error=result.error,
+                error_code="INVALID_GAUGE_RESULT",
+                tier="compile",
+                error_obj=ChartGenerationError(
+                    error_type=result.error_type,
+                    message="Gauge metric query returned invalid values",
+                    details=result.error,
+                    suggestions=[
+                        "Use a numeric-producing metric",
+                        "Check the metric alias and SQL expression",
+                    ],
+                    error_code="INVALID_GAUGE_RESULT",
+                ),
+            )
         for query in result.get("queries", []):
-            if query.get("error"):
-                error_str = str(query["error"])
-                return CompileResult(
-                    success=False,
-                    error=error_str,
-                    error_code="CHART_COMPILE_FAILED",
-                    tier="compile",
-                    error_obj=_build_compile_error(error_str),
-                )
             row_count += len(query.get("data", []))
 
         return CompileResult(success=True, warnings=warnings, row_count=row_count)
@@ -224,9 +232,13 @@ def _adhoc_filter_column_valid(
     """
     if clause == "HAVING":
         return DatasetValidator._column_exists(column, dataset_context)
-    return any(
-        col["name"].lower() == column.lower()
-        for col in dataset_context.available_columns
+    return (
+        resolve_dataset_reference(
+            column,
+            (col["name"] for col in dataset_context.available_columns),
+            "physical column",
+        )
+        is not None
     )
 
 
@@ -254,8 +266,11 @@ def _validate_adhoc_filter_columns(
         if not column or not isinstance(column, str):
             continue
         clause = f.get("clause", "WHERE").upper()
-        if not _adhoc_filter_column_valid(column, clause, dataset_context):
-            invalid.append(column)
+        try:
+            if not _adhoc_filter_column_valid(column, clause, dataset_context):
+                invalid.append(column)
+        except AmbiguousDatasetReferenceError as ex:
+            return DatasetValidator._build_ambiguous_reference_error(ex)
 
     if not invalid:
         return None
diff --git a/superset/mcp_service/chart/plugins/gauge.py b/superset/mcp_service/chart/plugins/gauge.py
index 723cd09ca20..97a2540bbf9 100644
--- a/superset/mcp_service/chart/plugins/gauge.py
+++ b/superset/mcp_service/chart/plugins/gauge.py
@@ -29,14 +29,17 @@ from superset.mcp_service.chart.chart_utils import (
 )
 from superset.mcp_service.chart.plugin import BaseChartPlugin
 from superset.mcp_service.chart.schemas import ColumnRef, GaugeChartConfig
-from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
+from superset.mcp_service.chart.validation.dataset_validator import (
+    DatasetValidator,
+    is_numeric_column,
+)
 from superset.mcp_service.common.error_schemas import ChartGenerationError
 
 
 class GaugeChartPlugin(BaseChartPlugin):
     """Plugin for gauge chart type."""
 
-    chart_type = "gauge_chart"
+    chart_type = "gauge"
     display_name = "Gauge Chart"
     native_viz_types: ClassVar[Mapping[str, str]] = {
         "gauge_chart": "Gauge Chart",
@@ -56,7 +59,7 @@ class GaugeChartPlugin(BaseChartPlugin):
                 ),
                 suggestions=[
                     "Add 'metric' field: {'name': 'progress', 'aggregate': 'AVG'}",
-                    "Example: {'chart_type': 'gauge_chart', "
+                    "Example: {'chart_type': 'gauge', "
                     "'metric': {'name': 'progress', 'aggregate': 'AVG'}}",
                 ],
                 error_code="MISSING_GAUGE_FIELDS",
@@ -69,6 +72,8 @@ class GaugeChartPlugin(BaseChartPlugin):
         refs: list[ColumnRef] = [config.metric]
         if config.groupby:
             refs.extend(config.groupby)
+        if config.granularity_sqla:
+            refs.append(ColumnRef(name=config.granularity_sqla))
         if config.filters:
             for f in config.filters:
                 refs.append(ColumnRef(name=f.column))
@@ -87,8 +92,62 @@ class GaugeChartPlugin(BaseChartPlugin):
     def resolve_viz_type(self, config: Any) -> str:
         return "gauge_chart"
 
+    def post_map_validate(
+        self,
+        config: Any,
+        form_data: dict[str, Any],
+        dataset_id: int | str | None = None,
+    ) -> ChartGenerationError | None:
+        """Reject definitively non-numeric SIMPLE Gauge metrics.
+
+        Saved and SQL metrics remain supported because their output type is not
+        reliably inferable from metadata; compile/data result validation checks
+        their concrete values instead.
+        """
+        if not isinstance(config, GaugeChartConfig):
+            return None
+        metric = config.metric
+        if (
+            dataset_id is None
+            or metric.saved_metric
+            or metric.sql_expression
+            or metric.aggregate in {"COUNT", "COUNT_DISTINCT"}
+            or metric.name is None
+        ):
+            return None
+        dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+        if dataset_context is None:
+            return None
+        column = next(
+            (
+                candidate
+                for candidate in dataset_context.available_columns
+                if candidate["name"] == metric.name
+            ),
+            None,
+        )
+        if column is None or is_numeric_column(column):
+            return None
+        type_name = str(column.get("type") or "UNKNOWN")
+        if type_name.strip().upper() in {"", "UNKNOWN"}:
+            return None
+        return ChartGenerationError(
+            error_type="non_numeric_gauge_metric",
+            message="Gauge metric must produce numeric values",
+            details=(
+                f"{metric.aggregate}({metric.name}) uses a non-numeric "
+                f"column of type {type_name}."
+            ),
+            suggestions=[
+                "Choose a numeric dataset column",
+                "Use COUNT or COUNT_DISTINCT for a categorical column",
+                "Use a saved or SQL metric that returns a numeric value",
+            ],
+            error_code="NON_NUMERIC_GAUGE_METRIC",
+        )
+
     def normalize_column_refs(self, config: Any, dataset_context: Any) -> Any:
-        config_dict = config.model_dump()
+        config_dict = config.model_dump(exclude_unset=True)
 
         if config_dict.get("metric"):
             if config_dict["metric"].get("sql_expression"):
@@ -110,6 +169,12 @@ class GaugeChartPlugin(BaseChartPlugin):
                 col["name"] = DatasetValidator.get_canonical_column_name(
                     col["name"], dataset_context
                 )
+        if config_dict.get("granularity_sqla"):
+            config_dict["granularity_sqla"] = (
+                DatasetValidator.get_canonical_column_name(
+                    config_dict["granularity_sqla"], dataset_context
+                )
+            )
         DatasetValidator.normalize_filters(config_dict, dataset_context)
         return GaugeChartConfig.model_validate(config_dict)
 
@@ -123,7 +188,7 @@ class GaugeChartPlugin(BaseChartPlugin):
             ),
             suggestions=[
                 "Ensure 'metric' field has 'name' and 'aggregate'",
-                "Example: {'chart_type': 'gauge_chart', "
+                "Example: {'chart_type': 'gauge', "
                 "'metric': {'name': 'progress', 'aggregate': 'AVG'}}",
             ],
             error_code="GAUGE_VALIDATION_ERROR",
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index a9e99e814e4..09f19c6439a 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -24,8 +24,14 @@ from form data without requiring a saved chart object.
 
 import logging
 import math
+from copy import deepcopy
 from typing import Any, Dict, List
 
+from superset.mcp_service.chart.query_result import (
+    metric_result_label,
+    normalize_gauge_query_result,
+    query_result_failure,
+)
 from superset.mcp_service.chart.schemas import (
     ASCIIPreview,
     ChartError,
@@ -75,43 +81,18 @@ def generate_preview_from_form_data(
                 error=f"Dataset {dataset_id} not found", error_type="DatasetNotFound"
             )
 
-        # Create query context from form data using factory
-        from superset.common.query_context_factory import QueryContextFactory
-        from superset.mcp_service.chart.chart_utils import (
-            adhoc_filters_to_query_filters,
-        )
-
-        # Build columns list: include x_axis and groupby for XY charts,
-        # fall back to form_data "columns" for table charts
-        columns = _build_query_columns(form_data)
-
-        query_filters = adhoc_filters_to_query_filters(
-            form_data.get("adhoc_filters", [])
+        from superset.mcp_service.chart.chart_helpers import (
+            build_query_context_from_form_data,
         )
 
-        # Big Number charts use singular "metric" instead of "metrics"
-        metrics = form_data.get("metrics", [])
-        if not metrics and form_data.get("metric"):
-            metrics = [form_data["metric"]]
-
-        # Big Number with trendline uses granularity_sqla as the time column
-        if not columns and form_data.get("granularity_sqla"):
-            columns = [form_data["granularity_sqla"]]
-
-        factory = QueryContextFactory()
-        query_context_obj = factory.create(
-            datasource={"id": dataset_id, "type": "table"},
-            queries=[
-                {
-                    "columns": columns,
-                    "metrics": metrics,
-                    "orderby": form_data.get("orderby", []),
-                    "row_limit": form_data.get("row_limit", 100),
-                    "filters": query_filters,
-                    "time_range": form_data.get("time_range", "No filter"),
-                }
-            ],
-            form_data=form_data,
+        query_form_data = deepcopy(form_data)
+        query_form_data["datasource"] = f"{dataset_id}__table"
+        query_form_data["datasource_id"] = dataset_id
+        query_form_data["datasource_type"] = "table"
+        query_context_obj = build_query_context_from_form_data(
+            query_form_data,
+            row_limit=form_data.get("row_limit", 100),
+            force=False,
         )
 
         # Execute query
@@ -119,6 +100,11 @@ def generate_preview_from_form_data(
         command.validate()
         result = command.run()
 
+        if query_failure := query_result_failure(result):
+            return query_failure
+        result = normalize_gauge_query_result(result, form_data)
+        if isinstance(result, ChartError):
+            return result
         if not result or not result.get("queries"):
             return ChartError(
                 error="No data returned from query", error_type="EmptyResult"
@@ -149,12 +135,17 @@ def generate_preview_from_form_data(
 
 def _generate_ascii_preview_from_data(
     data: List[Dict[str, Any]], form_data: Dict[str, Any]
-) -> ASCIIPreview:
+) -> ASCIIPreview | ChartError:
     """Generate ASCII preview from raw data."""
     viz_type = form_data.get("viz_type", "table")
 
     # Handle different chart types
-    if viz_type in ["bar", "dist_bar", "column"]:
+    if viz_type == "gauge_chart":
+        content_or_error = generate_gauge_ascii_preview(data, form_data)
+        if isinstance(content_or_error, ChartError):
+            return content_or_error
+        content = content_or_error
+    elif viz_type in ["bar", "dist_bar", "column"]:
         content = _generate_safe_ascii_bar_chart(data)
     elif viz_type in ["line", "area"]:
         content = _generate_safe_ascii_line_chart(data)
@@ -469,11 +460,481 @@ def _is_nan(value: Any) -> bool:
         return False
 
 
+_GAUGE_COLORS = (
+    "#1FA8C9",
+    "#454E7C",
+    "#5AC189",
+    "#FF7F44",
+    "#666666",
+    "#E04355",
+    "#FCC700",
+    "#A868B7",
+)
+
+
+def _gauge_column_label(column: Any) -> str | None:
+    """Resolve the result label for a native QueryFormColumn."""
+    if isinstance(column, str) and column:
+        return column
+    if not isinstance(column, dict) or not 0 < len(column) <= 20:
+        return None
+    label = (
+        column.get("label")
+        or column.get("sqlExpression")
+        or column.get("column_name")
+        or column.get("columnName")
+    )
+    return label if isinstance(label, str) and label else None
+
+
+def _parse_gauge_number_list(value: Any, field_name: str) -> list[float]:
+    """Parse a bounded comma-separated Gauge control."""
+    if value in (None, ""):
+        return []
+    if not isinstance(value, str) or len(value) > 1000:
+        raise ValueError(f"Gauge {field_name} must be a bounded string")
+    try:
+        parsed = [float(part.strip()) for part in value.split(",")]
+    except ValueError as ex:
+        raise ValueError(
+            f"Gauge {field_name} must be a comma-separated list of numbers"
+        ) from ex
+    if not all(math.isfinite(number) for number in parsed):
+        raise ValueError(f"Gauge {field_name} values must be finite")
+    return parsed
+
+
+def _prepare_gauge_preview(  # noqa: C901
+    data: Any, form_data: Dict[str, Any]
+) -> tuple[list[dict[str, Any]], dict[str, Any]] | ChartError:
+    """Validate Gauge rows and derive display values used by both previews."""
+    normalized = normalize_gauge_query_result(
+        {"queries": [{"data": data}]}, {**form_data, "viz_type": "gauge_chart"}
+    )
+    if isinstance(normalized, ChartError):
+        return normalized
+    data = normalized["queries"][0]["data"]
+    metric_label = metric_result_label(form_data.get("metric"))
+    if not isinstance(data, list) or metric_label is None:
+        return ChartError(
+            error="Gauge preview requires rows and a metric result label.",
+            error_type="InvalidGaugeFormData",
+        )
+
+    raw_groupby = form_data.get("groupby") or []
+    if isinstance(raw_groupby, str):
+        raw_groupby = [raw_groupby]
+    if not isinstance(raw_groupby, list) or len(raw_groupby) > 10:
+        return ChartError(
+            error="Gauge groupby must contain at most 10 column references.",
+            error_type="InvalidGaugeFormData",
+        )
+    group_labels: list[str] = []
+    for index, column in enumerate(raw_groupby):
+        label = _gauge_column_label(column)
+        if label is None:
+            return ChartError(
+                error=f"Gauge groupby[{index}] has no resolvable result label.",
+                error_type="InvalidGaugeFormData",
+            )
+        group_labels.append(label)
+
+    values = [float(row[metric_label]) for row in data]
+
+    def numeric_bound(field_name: str) -> float | None:
+        value = form_data.get(field_name)
+        if value in (None, ""):
+            return None
+        if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+            raise ValueError(f"Gauge {field_name} must be numeric")
+        try:
+            converted = float(value)
+        except ValueError as ex:
+            raise ValueError(f"Gauge {field_name} must be numeric") from ex
+        if not math.isfinite(converted):
+            raise ValueError(f"Gauge {field_name} must be finite")
+        return converted
+
+    try:
+        minimum = numeric_bound("min_val")
+        maximum = numeric_bound("max_val")
+        interval_bounds = _parse_gauge_number_list(
+            form_data.get("intervals", ""), "intervals"
+        )
+        color_indices = _parse_gauge_number_list(
+            form_data.get("interval_color_indices", ""),
+            "interval_color_indices",
+        )
+    except ValueError as ex:
+        return ChartError(error=str(ex), error_type="InvalidGaugeFormData")
+
+    configured_minimum, configured_maximum = minimum, maximum
+
+    # Match transformProps auto-range semantics: twice the extrema including 0.
+    if minimum is None:
+        minimum = 2 * min([*values, 0]) if values else 0
+    if maximum is None:
+        maximum = 2 * max([*values, 0]) if values else 1
+    if configured_minimum is None and configured_maximum is None and minimum == maximum:
+        maximum = minimum + 1
+    if minimum >= maximum:
+        return ChartError(
+            error=(
+                f"Gauge preview range is invalid: min_val {minimum:g} must be "
+                f"less than max_val {maximum:g}."
+            ),
+            error_type="InvalidGaugeRange",
+        )
+    if any(
+        left >= right
+        for left, right in zip(interval_bounds, interval_bounds[1:], strict=False)
+    ):
+        return ChartError(
+            error="Gauge intervals must be strictly increasing.",
+            error_type="InvalidGaugeFormData",
+        )
+    if any(
+        (configured_minimum is not None and bound <= configured_minimum)
+        or (configured_maximum is not None and bound > configured_maximum)
+        for bound in interval_bounds
+    ):
+        return ChartError(
+            error="Gauge intervals must fall within the configured min/max range.",
+            error_type="InvalidGaugeFormData",
+        )
+    if color_indices and len(color_indices) != len(interval_bounds):
+        return ChartError(
+            error="Gauge interval colors must match the number of interval bounds.",
+            error_type="InvalidGaugeFormData",
+        )
+    if any(index < 1 or not index.is_integer() for index in color_indices):
+        return ChartError(
+            error="Gauge interval color indices must be positive integers.",
+            error_type="InvalidGaugeFormData",
+        )
+
+    currency = form_data.get("currency_format")
+    if currency is not None and not isinstance(currency, dict):
+        return ChartError(
+            error="Gauge currency_format must be an object.",
+            error_type="InvalidGaugeFormData",
+        )
+    number_format = form_data.get("number_format", "SMART_NUMBER")
+    value_formatter = form_data.get("value_formatter", "{value}")
+    if not isinstance(number_format, str) or not isinstance(value_formatter, str):
+        return ChartError(
+            error="Gauge number_format and value_formatter must be strings.",
+            error_type="InvalidGaugeFormData",
+        )
+    from superset.utils.number_format import format_number_with_config
+
+    decorated: list[dict[str, Any]] = []
+    for row_index, row in enumerate(data):
+        value = float(row[metric_label])
+        group = (
+            ", ".join(f"{label}: {row.get(label)}" for label in group_labels) or "Value"
+        )
+        formatted = format_number_with_config(number_format, currency, value)
+        decorated.append(
+            {
+                **row,
+                "__mcp_gauge_group": group,
+                "__mcp_gauge_ratio": (value - minimum) / (maximum - minimum),
+                "__mcp_gauge_display": value_formatter.replace(
+                    "{value}", str(formatted), 1
+                ),
+                "__mcp_gauge_row": row_index,
+            }
+        )
+
+    colors = [
+        _GAUGE_COLORS[(int(index) - 1) % len(_GAUGE_COLORS)] for index in color_indices
+    ] or list(_GAUGE_COLORS[: max(1, len(interval_bounds))])
+    return decorated, {
+        "metric_label": metric_label,
+        "group_labels": group_labels,
+        "minimum": minimum,
+        "maximum": maximum,
+        "interval_bounds": interval_bounds,
+        "interval_colors": colors,
+    }
+
+
+def generate_gauge_ascii_preview(
+    data: Any, form_data: Dict[str, Any], width: int = 80
+) -> str | ChartError:
+    """Render a bounded dial-like ASCII Gauge using the configured value format."""
+    prepared = _prepare_gauge_preview(data, form_data)
+    if isinstance(prepared, ChartError):
+        return prepared
+    decorated, metadata = prepared
+    minimum = metadata["minimum"]
+    maximum = metadata["maximum"]
+    lines = [
+        "Gauge Chart",
+        f"Range: {minimum:g} to {maximum:g}",
+    ]
+    if metadata["interval_bounds"]:
+        lines.append(
+            "Intervals: "
+            + ", ".join(f"{value:g}" for value in metadata["interval_bounds"])
+        )
+    if not decorated:
+        lines.append("No data available")
+        return "\n".join(lines)
+
+    width = min(max(width, 40), 200)
+    bar_width = max(10, min(40, width - 38))
+    for row in decorated[:10]:
+        ratio = min(1.0, max(0.0, row["__mcp_gauge_ratio"]))
+        filled = round(ratio * bar_width)
+        bar = "█" * filled + "░" * (bar_width - filled)
+        label = str(row["__mcp_gauge_group"])
+        lines.append(f"{label[:20]:>20} [{bar}] {row['__mcp_gauge_display']}")
+    return "\n".join(lines)
+
+
+def generate_gauge_vega_lite_preview(  # noqa: C901
+    data: Any, form_data: Dict[str, Any]
+) -> VegaLitePreview | ChartError:
+    """Build a Gauge-specific layered radial Vega-Lite preview."""
+    prepared = _prepare_gauge_preview(data, form_data)
+    if isinstance(prepared, ChartError):
+        return prepared
+    decorated, metadata = prepared
+    start_angle = form_data.get("start_angle", 225)
+    end_angle = form_data.get("end_angle", -45)
+
+    def finite_angle(value: Any) -> float | None:
+        if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+            return None
+        try:
+            converted = float(value)
+        except ValueError:
+            return None
+        return converted if math.isfinite(converted) else None
+
+    numeric_start_angle = finite_angle(start_angle)
+    numeric_end_angle = finite_angle(end_angle)
+    if numeric_start_angle is None or numeric_end_angle is None:
+        return ChartError(
+            error="Gauge start_angle and end_angle must be finite numbers.",
+            error_type="InvalidGaugeFormData",
+        )
+
+    angle_range = [math.radians(numeric_start_angle), math.radians(numeric_end_angle)]
+    theta_scale = {"domain": [0, 1], "range": angle_range}
+    metric_label = metadata["metric_label"]
+    tooltip = [
+        {"field": field, "type": "nominal"} for field in metadata["group_labels"]
+    ]
+    tooltip.extend(
+        [
+            {"field": metric_label, "type": "quantitative"},
+            {"field": "__mcp_gauge_display", "type": "nominal", "title": "Value"},
+        ]
+    )
+    progress_color: dict[str, Any]
+    interval_bounds = metadata["interval_bounds"]
+    if interval_bounds:
+        progress_color = {
+            "field": metric_label,
+            "type": "quantitative",
+            "scale": {
+                "type": "threshold",
+                "domain": interval_bounds[:-1],
+                "range": metadata["interval_colors"],
+            },
+            "legend": None,
+        }
+    elif metadata["group_labels"]:
+        progress_color = {
+            "field": "__mcp_gauge_group",
+            "type": "nominal",
+            "legend": None,
+        }
+    else:
+        progress_color = {"value": _GAUGE_COLORS[0]}
+
+    inner_radius, outer_radius = 55, 82
+    pointer_radius = (inner_radius + outer_radius) / 2
+    background_layers: list[dict[str, Any]] = []
+    previous_ratio = 0.0
+    for bound, color in zip(interval_bounds, metadata["interval_colors"], strict=False):
+        # Automatic bounds can put configured thresholds outside the visible dial.
+        ratio = min(
+            1.0,
+            max(
+                0.0,
+                (bound - metadata["minimum"])
+                / (metadata["maximum"] - metadata["minimum"]),
+            ),
+        )
+        if ratio <= previous_ratio:
+            continue
+        background_layers.append(
+            {
+                "mark": {
+                    "type": "arc",
+                    "innerRadius": inner_radius,
+                    "outerRadius": outer_radius,
+                    "color": color,
+                    "opacity": 0.35,
+                },
+                "encoding": {
+                    "theta": {
+                        "datum": previous_ratio,
+                        "type": "quantitative",
+                        "scale": theta_scale,
+                        "stack": None,
+                    },
+                    "theta2": {"datum": ratio},
+                },
+            }
+        )
+        previous_ratio = ratio
+    if not background_layers:
+        background_layers.append(
+            {
+                "mark": {
+                    "type": "arc",
+                    "innerRadius": inner_radius,
+                    "outerRadius": outer_radius,
+                    "color": "#D3D3D3",
+                },
+                "encoding": {
+                    "theta": {
+                        "datum": 1,
+                        "type": "quantitative",
+                        "scale": theta_scale,
+                        "stack": None,
+                    },
+                    "theta2": {"datum": 0},
+                },
+            }
+        )
+
+    progress_layer = {
+        "mark": {
+            "type": "arc",
+            "innerRadius": inner_radius,
+            "outerRadius": outer_radius,
+            "cornerRadius": 4 if form_data.get("round_cap") else 0,
+            "tooltip": True,
+        },
+        "encoding": {
+            "theta": {
+                "field": "__mcp_gauge_ratio",
+                "type": "quantitative",
+                "scale": theta_scale,
+                "stack": None,
+            },
+            "theta2": {"datum": 0},
+            "color": progress_color,
+            "tooltip": tooltip,
+        },
+    }
+    facet_width, dial_height = 200, 300
+    # Faceted value expressions cannot use the outer width/height signals.
+    center_x = str(facet_width / 2) if metadata["group_labels"] else "width / 2"
+    center_y = str(dial_height / 2) if metadata["group_labels"] else "height / 2"
+    pointer_layer = {
+        "transform": [
+            {
+                "calculate": (
+                    f"{angle_range[0]} + datum.__mcp_gauge_ratio * "
+                    f"({angle_range[1]} - {angle_range[0]})"
+                ),
+                "as": "__mcp_gauge_angle",
+            },
+        ],
+        "mark": {"type": "rule", "strokeWidth": 3, "color": "#444"},
+        "encoding": {
+            "x": {"value": {"expr": center_x}},
+            "y": {"value": {"expr": center_y}},
+            "x2": {
+                "value": {
+                    "expr": (
+                        f"{center_x} + {pointer_radius} * sin(datum.__mcp_gauge_angle)"
+                    )
+                }
+            },
+            "y2": {
+                "value": {
+                    "expr": (
+                        f"{center_y} - {pointer_radius} * cos(datum.__mcp_gauge_angle)"
+                    )
+                }
+            },
+            "tooltip": tooltip,
+        },
+    }
+    text_layer = {
+        "mark": {"type": "text", "fontSize": form_data.get("font_size", 15)},
+        "encoding": {"text": {"field": "__mcp_gauge_display", "type": "nominal"}},
+    }
+    layers: list[dict[str, Any]] = [*background_layers]
+    if form_data.get("show_progress", True):
+        layers.append(progress_layer)
+    if form_data.get("show_pointer", True):
+        layers.append(pointer_layer)
+    layers.append(text_layer)
+    unit_spec: dict[str, Any] = {"layer": layers}
+    specification: dict[str, Any] = {
+        "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+        "data": {"values": decorated},
+        "width": "container",
+        "height": dial_height,
+        "usermeta": {
+            "viz_type": "gauge_chart",
+            "min_val": metadata["minimum"],
+            "max_val": metadata["maximum"],
+            "intervals": interval_bounds,
+            "interval_color_indices": form_data.get("interval_color_indices", ""),
+            "color_scheme": form_data.get("color_scheme"),
+            "show_pointer": form_data.get("show_pointer", True),
+            "show_progress": form_data.get("show_progress", True),
+            "show_axis_tick": form_data.get("show_axis_tick", False),
+            "show_split_line": form_data.get("show_split_line", False),
+            "split_number": form_data.get("split_number", 10),
+            "overlap": form_data.get("overlap", True),
+            "round_cap": form_data.get("round_cap", False),
+            "animation": form_data.get("animation", True),
+            "font_size": form_data.get("font_size", 15),
+            "number_format": form_data.get("number_format", "SMART_NUMBER"),
+            "value_formatter": form_data.get("value_formatter", "{value}"),
+        },
+    }
+    if metadata["group_labels"]:
+        specification.pop("width")
+        unit_spec["width"] = facet_width
+        unit_spec["height"] = specification.pop("height")
+        specification.update(
+            {
+                "facet": {
+                    "field": "__mcp_gauge_group",
+                    "type": "nominal",
+                },
+                "columns": min(3, max(1, len(decorated))),
+                "spec": unit_spec,
+            }
+        )
+    else:
+        specification.update(unit_spec)
+    return VegaLitePreview(
+        specification=specification,
+        data_url=None,
+        supports_streaming=False,
+    )
+
+
 def _generate_vega_lite_preview_from_data(  # noqa: C901
     data: List[Dict[str, Any]], form_data: Dict[str, Any]
-) -> VegaLitePreview:
+) -> VegaLitePreview | ChartError:
     """Generate Vega-Lite preview from raw data and form_data."""
     viz_type = form_data.get("viz_type", "table")
+    if viz_type == "gauge_chart":
+        return generate_gauge_vega_lite_preview(data, form_data)
 
     # Map Superset viz types to Vega-Lite marks
     viz_to_mark = {
diff --git a/superset/mcp_service/chart/query_result.py b/superset/mcp_service/chart/query_result.py
new file mode 100644
index 00000000000..bc468be316a
--- /dev/null
+++ b/superset/mcp_service/chart/query_result.py
@@ -0,0 +1,225 @@
+# 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.
+
+"""Helpers for interpreting ChartDataCommand result envelopes."""
+
+import math
+from collections.abc import Mapping
+from typing import Any
+
+from superset.mcp_service.chart.schemas import ChartError
+
+FAILED_QUERY_STATUSES = frozenset(
+    {"error", "failed", "stopped", "timed_out", "cancelled", "canceled"}
+)
+
+
+def _query_error_text(value: Any) -> str | None:
+    """Convert a bounded query error payload into a useful message."""
+    if value is None or value is False:
+        return None
+    if isinstance(value, Mapping):
+        for key in ("error", "error_message", "message", "detail"):
+            if text := _query_error_text(value.get(key)):
+                return text
+        return None
+    if isinstance(value, (list, tuple)):
+        parts = [text for item in value if (text := _query_error_text(item))]
+        return "; ".join(parts[:3]) or None
+    text = str(value)
+    return text[:2000] if text else None
+
+
+def _failure_for_query_payload(
+    payload: Mapping[str, Any], label: str
+) -> ChartError | None:
+    """Extract one failure from a top-level or per-query payload."""
+    for key in ("error", "errors", "error_message"):
+        if message := _query_error_text(payload.get(key)):
+            return ChartError(
+                error=f"{label} failed: {message}", error_type="QueryError"
+            )
+
+    raw_status = payload.get("status")
+    status = str(getattr(raw_status, "value", raw_status) or "")
+    normalized_status = status.strip().casefold().replace("-", "_").replace(" ", "_")
+    if normalized_status in FAILED_QUERY_STATUSES:
+        message = (
+            _query_error_text(payload.get("message"))
+            or _query_error_text(payload.get("error_message"))
+            or normalized_status
+        )
+        return ChartError(error=f"{label} failed: {message}", error_type="QueryError")
+    if payload.get("success") is False:
+        message = _query_error_text(payload.get("message")) or "request failed"
+        return ChartError(error=f"{label} failed: {message}", error_type="QueryError")
+    if (
+        raw_status is None
+        and "data" not in payload
+        and "queries" not in payload
+        and (message := _query_error_text(payload.get("message")))
+    ):
+        return ChartError(error=f"{label} failed: {message}", error_type="QueryError")
+    return None
+
+
+def query_result_failure(result: Any) -> ChartError | None:
+    """Return a structured failure embedded in a ChartDataCommand payload."""
+    if not isinstance(result, Mapping):
+        return None
+
+    if failure := _failure_for_query_payload(result, "Chart query"):
+        return failure
+    queries = result.get("queries")
+    if isinstance(queries, list):
+        for index, query in enumerate(queries, start=1):
+            if isinstance(query, Mapping) and (
+                failure := _failure_for_query_payload(query, f"Chart query {index}")
+            ):
+                return failure
+    return None
+
+
+def metric_result_label(metric: Any) -> str | None:
+    """Resolve the query-result key using frontend ``getMetricLabel`` rules."""
+    if isinstance(metric, str) and metric:
+        return metric
+    if not isinstance(metric, Mapping):
+        return None
+    label = metric.get("label")
+    if isinstance(label, str) and label:
+        return label
+    if label not in (None, ""):
+        return None
+    expression_type = metric.get("expressionType")
+    if expression_type == "SIMPLE":
+        aggregate = metric.get("aggregate")
+        column = metric.get("column")
+        if not isinstance(aggregate, str) or not isinstance(column, Mapping):
+            return None
+        column_name = column.get("columnName") or column.get("column_name")
+        if isinstance(column_name, str) and column_name:
+            return f"{aggregate}({column_name})"
+        return None
+    if expression_type == "SQL":
+        sql_expression = metric.get("sqlExpression")
+        if isinstance(sql_expression, str) and sql_expression:
+            return sql_expression
+    return None
+
+
+def normalize_gauge_query_result(  # noqa: C901
+    result: Any, form_data: Mapping[str, Any]
+) -> Any:
+    """Copy Gauge query envelopes with only finite dials; preserve empty results.
+
+    Malformed envelopes/aliases remain errors. Invalid dial values are skipped,
+    but a nonempty query with no finite dials returns an actionable error.
+    Non-Gauge results and the caller's query payloads are left untouched.
+    """
+    if form_data.get("viz_type") != "gauge_chart":
+        return result
+    if failure := query_result_failure(result):
+        return failure
+
+    metric_label = metric_result_label(form_data.get("metric"))
+    if metric_label is None:
+        return ChartError(
+            error="Gauge form_data has no resolvable metric result label.",
+            error_type="InvalidGaugeFormData",
+        )
+    if not isinstance(result, Mapping):
+        return ChartError(
+            error="Gauge query result is not an object.",
+            error_type="InvalidGaugeResult",
+        )
+    queries = result.get("queries")
+    if not isinstance(queries, list):
+        return ChartError(
+            error="Gauge query result has no queries array.",
+            error_type="InvalidGaugeResult",
+        )
+    normalized_queries = []
+    for query_index, query in enumerate(queries):
+        if not isinstance(query, Mapping):
+            return ChartError(
+                error=f"Gauge query {query_index} is not an object.",
+                error_type="InvalidGaugeResult",
+            )
+        data = query.get("data", [])
+        if not isinstance(data, list):
+            return ChartError(
+                error=f"Gauge query {query_index} data is not an array of rows.",
+                error_type="InvalidGaugeResult",
+            )
+        finite_rows = []
+        value_error = None
+        for row_index, row in enumerate(data):
+            if not isinstance(row, Mapping):
+                return ChartError(
+                    error=(
+                        f"Gauge query {query_index} row {row_index} is not an object."
+                    ),
+                    error_type="InvalidGaugeResult",
+                )
+            if metric_label not in row:
+                return ChartError(
+                    error=(
+                        f"Gauge query {query_index} row {row_index} is missing "
+                        f"metric output {metric_label!r}."
+                    ),
+                    error_type="InvalidGaugeResult",
+                )
+            value = row[metric_label]
+            if isinstance(value, bool) or not isinstance(value, (int, float)):
+                value_error = value_error or ChartError(
+                    error=(
+                        f"Gauge query {query_index} row {row_index} metric "
+                        f"{metric_label!r} is not numeric."
+                    ),
+                    error_type="NonNumericGaugeMetric",
+                )
+                continue
+            try:
+                finite = math.isfinite(float(value))
+            except (OverflowError, ValueError):
+                finite = False
+            if not finite:
+                value_error = value_error or ChartError(
+                    error=(
+                        f"Gauge query {query_index} row {row_index} metric "
+                        f"{metric_label!r} is not finite."
+                    ),
+                    error_type="NonFiniteGaugeMetric",
+                )
+                continue
+            finite_rows.append(row)
+        if data and not finite_rows:
+            return value_error
+        normalized_query = {**query, "data": finite_rows}
+        if len(finite_rows) != len(data) and "rowcount" in query:
+            normalized_query["rowcount"] = len(finite_rows)
+        normalized_queries.append(normalized_query)
+    return {**result, "queries": normalized_queries}
+
+
+def validate_gauge_query_result(
+    result: Any, form_data: Mapping[str, Any]
+) -> ChartError | None:
+    """Check Gauge results using the same finite-dial contract as rendering."""
+    normalized = normalize_gauge_query_result(result, form_data)
+    return normalized if isinstance(normalized, ChartError) else None
diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py
index 81cac8f8080..c76046ae97a 100644
--- a/superset/mcp_service/chart/resources/chart_configs.py
+++ b/superset/mcp_service/chart/resources/chart_configs.py
@@ -262,6 +262,24 @@ def get_chart_configs_resource() -> str:
             ],
         }
 
+    # Gauge configs
+    gauge_configs = {
+        "attainment_gauge": {
+            "description": "Gauge with a target range and formatted value",
+            "config": {
+                "chart_type": "gauge",
+                "metric": {"name": "attainment", "aggregate": "AVG"},
+                "min_val": 0,
+                "max_val": 100,
+                "number_format": ".1f",
+                "value_formatter": "{value}%",
+                "intervals": "50,80,100",
+                "interval_color_indices": "6,7,3",
+            },
+            "use_cases": ["Target attainment", "SLA health", "Progress tracking"],
+        }
+    }
+
     # Best practices
     best_practices = {
         "xy_charts": [
@@ -284,6 +302,11 @@ def get_chart_configs_resource() -> str:
             "Pair comparison_period with comparison_type for time comparison",
             "This type appears only when the host enables its visualization plugin",
         ],
+        "gauge_charts": [
+            "Use the public chart_type='gauge'; gauge_chart is the native viz_type",
+            "Use an aggregate, saved metric, or SQL metric that returns finite numbers",
+            "Set min_val/max_val and aligned intervals for a stable shared scale",
+        ],
         "general": [
             "Always verify column names with get_dataset_info before charting",
             "Set temporal_column when dashboard time filters should use a column "
@@ -298,6 +321,7 @@ def get_chart_configs_resource() -> str:
         "xy_chart_configs": xy_chart_configs,
         "table_chart_configs": table_chart_configs,
         "interactive_pivot_configs": interactive_pivot_configs,
+        "gauge_configs": gauge_configs,
         "best_practices": best_practices,
         "usage_notes": [
             "All examples are valid ChartConfig objects that pass validation",
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index 740de721c63..46f0054af10 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -23,9 +23,10 @@ from __future__ import annotations
 
 import difflib
 import logging
+import math
 import re
 from datetime import datetime
-from typing import Annotated, Any, Dict, List, Literal, Protocol
+from typing import Annotated, Any, Dict, get_args, List, Literal, Protocol
 
 from pydantic import (
     AliasChoices,
@@ -41,7 +42,7 @@ from pydantic import (
 )
 from typing_extensions import Self
 
-from superset.constants import TimeGrain
+from superset.constants import NO_TIME_RANGE, TimeGrain
 from superset.daos.base import ColumnOperator, ColumnOperatorEnum
 from superset.mcp_service.common.cache_schemas import (
     CacheStatus,
@@ -56,6 +57,7 @@ from superset.mcp_service.common.pagination_schemas import (
     PaginatedListRequest,
     PaginatedResponse,
 )
+from superset.mcp_service.common.time_range_validation import validate_time_range
 from superset.mcp_service.privacy import (
     filter_user_directory_fields,
     strip_user_directory_fields_from_schema,
@@ -1055,9 +1057,11 @@ class GaugeChartConfig(BaseChartConfig):
     (both default to auto).
     """
 
-    model_config = ConfigDict(extra="ignore", populate_by_name=True)
+    model_config = ConfigDict(
+        extra="ignore", populate_by_name=True, allow_inf_nan=False
+    )
 
-    chart_type: Literal["gauge_chart"] = "gauge_chart"
+    chart_type: Literal["gauge"] = "gauge"
     metric: ColumnRef = Field(
         ...,
         description="Value metric the dial displays (use aggregate e.g. AVG, "
@@ -1093,10 +1097,200 @@ class GaugeChartConfig(BaseChartConfig):
         ),
         max_length=100,
     )
+    font_size: int = Field(15, description="Gauge text size", ge=10, le=20)
+    number_format: str = Field(
+        "SMART_NUMBER", description="D3 number format", max_length=50
+    )
+    currency_format: CurrencyFormat | None = Field(
+        None, description="Currency symbol applied to the gauge value"
+    )
+    value_formatter: str = Field(
+        "{value}",
+        description="Value template; {value} is replaced with the formatted metric",
+        max_length=200,
+    )
+    start_angle: float = Field(225, description="Gauge start angle in degrees")
+    end_angle: float = Field(-45, description="Gauge end angle in degrees")
+    show_pointer: bool = Field(True, description="Show the gauge pointer")
+    animation: bool = Field(True, description="Animate gauge value changes")
+    show_axis_tick: bool = Field(False, description="Show minor axis ticks")
+    show_split_line: bool = Field(False, description="Show axis split lines")
+    split_number: int = Field(10, description="Number of axis segments", ge=3, le=30)
+    show_progress: bool = Field(True, description="Show the progress arc")
+    overlap: bool = Field(
+        True, description="Overlap progress arcs when multiple groups are present"
+    )
+    round_cap: bool = Field(False, description="Use rounded progress-arc caps")
+    intervals: str = Field(
+        "",
+        description="Comma-separated interval upper bounds",
+        max_length=1000,
+    )
+    interval_color_indices: str = Field(
+        "",
+        description="Comma-separated 1-based color indices for intervals",
+        max_length=1000,
+    )
+    time_range: str | None = Field(
+        None,
+        description="Optional Superset time range applied to the gauge query",
+        max_length=1000,
+    )
+    granularity_sqla: str | None = Field(
+        None,
+        description="Temporal column associated with time_range in native form_data",
+        min_length=1,
+        max_length=255,
+    )
+
+    @model_validator(mode="before")
+    @classmethod
+    def adapt_native_form_data(cls, data: Any) -> Any:  # noqa: C901
+        """Accept the Gauge plugin's native form_data without weakening typing."""
+        if not isinstance(data, dict):
+            return data
+        data = dict(data)
+
+        # ``gauge`` is the public MCP discriminator; ``gauge_chart`` remains
+        # the native frontend viz_type and is accepted only as an input alias.
+        if data.get("chart_type") == "gauge_chart" or (
+            "chart_type" not in data and data.get("viz_type") == "gauge_chart"
+        ):
+            data["chart_type"] = "gauge"
+        data.pop("viz_type", None)
+
+        # These identify the Explore/chart envelope, not Gauge controls.
+        for key in (
+            "datasource",
+            "datasource_id",
+            "datasource_name",
+            "datasource_type",
+            "form_data_key",
+            "slice_id",
+            "slice_name",
+            "url",
+        ):
+            data.pop(key, None)
+        data.pop("_mcp_dashboard_time_filter_subject", None)
+
+        metric = data.get("metric")
+        if isinstance(metric, str):
+            data["metric"] = {"name": metric, "saved_metric": True}
+        elif isinstance(metric, dict) and metric.get("expressionType") in {
+            "SIMPLE",
+            "SQL",
+        }:
+            expression_type = metric.get("expressionType")
+            if expression_type == "SQL":
+                data["metric"] = {
+                    "sql_expression": metric.get("sqlExpression"),
+                    "label": metric.get("label"),
+                }
+            else:
+                column = metric.get("column")
+                column_name = (
+                    column.get("column_name") or column.get("columnName")
+                    if isinstance(column, dict)
+                    else None
+                )
+                data["metric"] = {
+                    "name": column_name,
+                    "aggregate": metric.get("aggregate"),
+                    "label": metric.get("label"),
+                }
+
+        groupby = data.get("groupby")
+        if isinstance(groupby, str):
+            groupby = [groupby]
+        if isinstance(groupby, list):
+            data["groupby"] = [
+                {"name": value} if isinstance(value, str) else value
+                for value in groupby
+            ]
+
+        if isinstance(data.get("time_range"), str):
+            data["time_range"] = validate_time_range(data["time_range"]) or None
+
+        # Supported native SIMPLE filters are represented by FilterConfig.
+        # SQL adhoc filters remain intentionally unsupported on the typed MCP
+        # surface. TEMPORAL_RANGE is represented by time_range/granularity.
+        if "adhoc_filters" in data:
+            if "filters" in data:
+                raise ValueError("Use either filters or adhoc_filters, not both")
+            native_filters = data.pop("adhoc_filters")
+            if not isinstance(native_filters, list):
+                raise ValueError("adhoc_filters must be a list")
+            filters: list[dict[str, Any]] = []
+            for index, filter_ in enumerate(native_filters):
+                if not isinstance(filter_, dict):
+                    raise ValueError(f"adhoc_filters[{index}] must be an object")
+                if filter_.get("expressionType") not in (None, "SIMPLE"):
+                    raise ValueError(
+                        f"adhoc_filters[{index}] must use expressionType='SIMPLE'"
+                    )
+                if str(filter_.get("clause", "WHERE")).upper() != "WHERE":
+                    raise ValueError(f"adhoc_filters[{index}] must use clause='WHERE'")
+                operator = filter_.get("operator") or filter_.get("op")
+                subject = filter_.get("subject") or filter_.get("col")
+                comparator = filter_.get("comparator", filter_.get("val"))
+                if operator == "TEMPORAL_RANGE":
+                    if not isinstance(subject, str) or not subject:
+                        raise ValueError(
+                            f"adhoc_filters[{index}] has no temporal subject"
+                        )
+                    if not isinstance(comparator, str):
+                        raise ValueError(
+                            f"adhoc_filters[{index}] requires a temporal comparator"
+                        )
+                    comparator = validate_time_range(comparator) or NO_TIME_RANGE
+                    if comparator == NO_TIME_RANGE:
+                        if data.get("temporal_column") not in (None, subject):
+                            raise ValueError(
+                                f"adhoc_filters[{index}] conflicts with another "
+                                "dashboard temporal binding"
+                            )
+                        data["temporal_column"] = subject
+                        continue
+                    if (
+                        data.get("granularity_sqla") not in (None, subject)
+                        and data.get("time_range") != NO_TIME_RANGE
+                    ) or data.get("time_range") not in (
+                        None,
+                        NO_TIME_RANGE,
+                        comparator,
+                    ):
+                        raise ValueError(
+                            f"adhoc_filters[{index}] conflicts with another temporal "
+                            "range; multiple distinct temporal ranges are not supported"
+                        )
+                    data["granularity_sqla"] = subject
+                    data["time_range"] = comparator
+                    continue
+                if operator == "==":
+                    operator = "="
+                if operator not in get_args(FilterConfig.model_fields["op"].annotation):
+                    raise ValueError(
+                        f"adhoc_filters[{index}] uses unsupported operator {operator!r}"
+                    )
+                filters.append({"column": subject, "op": operator, "value": comparator})
+            data["filters"] = filters
+        return data
+
+    @field_validator("time_range")
+    @classmethod
+    def validate_gauge_time_range(cls, value: str | None) -> str | None:
+        """Validate time ranges with the shared MCP parser contract."""
+        return validate_time_range(value)
 
     @model_validator(mode="after")
     def reject_metric_style_groupby(self) -> "GaugeChartConfig":
-        """groupby entries are dimensions, not metrics."""
+        """Require one numeric metric role and unique dimension roles."""
+        if not self.metric.is_metric:
+            raise ValueError(
+                "metric must define an aggregate, saved_metric=True, or a "
+                "sql_expression"
+            )
+        seen: set[str] = set()
         for i, col in enumerate(self.groupby or []):
             _reject_sql_expression_on_dimension(col, f"groupby[{i}]")
             if col.is_metric:
@@ -1105,6 +1299,14 @@ class GaugeChartConfig(BaseChartConfig):
                     "'aggregate'/'saved_metric' (metrics belong in the 'metric' "
                     "field)"
                 )
+            if col.name is None:
+                raise ValueError(f"groupby[{i}] requires a column name")
+            normalized_name = col.name.casefold()
+            if normalized_name in seen:
+                raise ValueError(
+                    f"groupby[{i}] duplicates the dimension role '{col.name}'"
+                )
+            seen.add(normalized_name)
         return self
 
     @model_validator(mode="after")
@@ -1120,6 +1322,41 @@ class GaugeChartConfig(BaseChartConfig):
             )
         return self
 
+    @model_validator(mode="after")
+    def validate_intervals(self) -> "GaugeChartConfig":
+        """Keep interval bounds finite, ordered, and aligned with color picks."""
+
+        def parse_numbers(value: str, field_name: str) -> list[float]:
+            if not value.strip():
+                return []
+            try:
+                parsed = [float(part.strip()) for part in value.split(",")]
+            except ValueError as ex:
+                raise ValueError(
+                    f"{field_name} must be a comma-separated list of numbers"
+                ) from ex
+            if not all(math.isfinite(number) for number in parsed):
+                raise ValueError(f"{field_name} values must be finite")
+            return parsed
+
+        bounds = parse_numbers(self.intervals, "intervals")
+        color_indices = parse_numbers(
+            self.interval_color_indices, "interval_color_indices"
+        )
+        if any(not number.is_integer() or number < 1 for number in color_indices):
+            raise ValueError("interval_color_indices must contain positive integers")
+        if color_indices and len(color_indices) != len(bounds):
+            raise ValueError(
+                "interval_color_indices must have the same length as intervals"
+            )
+        if any(left >= right for left, right in zip(bounds, bounds[1:], strict=False)):
+            raise ValueError("intervals must be strictly increasing")
+        if self.min_val is not None and any(bound <= self.min_val for bound in bounds):
+            raise ValueError("intervals must be greater than min_val")
+        if self.max_val is not None and any(bound > self.max_val for bound in bounds):
+            raise ValueError("intervals must not exceed max_val")
+        return self
+
 
 class PivotTableChartConfig(BaseChartConfig):
     model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2376,7 +2613,7 @@ ChartConfig = Annotated[
         discriminator="chart_type",
         description=(
             "Chart configuration - specify chart_type as 'xy', 'table', "
-            "'pie', 'gauge_chart', 'pivot_table', 'interactive_pivot', "
+            "'pie', 'gauge', 'pivot_table', 'interactive_pivot', "
             "'mixed_timeseries', 'handlebars', "
             "'big_number', 'histogram', 'box_plot', or 'waterfall'"
         ),
@@ -2408,6 +2645,7 @@ _VIZ_TYPE_TO_CHART_TYPE: dict[str, tuple[str, str | None]] = {
     "pivot_table_v2": ("pivot_table", None),
     "ag-grid-pivot-table": ("interactive_pivot", None),
     "histogram_v2": ("histogram", None),
+    "gauge_chart": ("gauge", None),
 }
 
 
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index 0ba1790f722..0bef46e299b 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -86,7 +86,7 @@ async def generate_chart(  # noqa: C901
     - LLM clients MUST display returned chart URL to users
     - Use numeric dataset ID or UUID (NOT schema.table_name format)
     - MUST include chart_type in config (one of: 'xy', 'table', 'pie',
-      'gauge_chart', 'pivot_table', 'mixed_timeseries', 'handlebars',
+      'gauge', 'pivot_table', 'mixed_timeseries', 'handlebars',
       'big_number', 'histogram', 'box_plot', 'waterfall', plus host-gated
       types returned by get_chart_type_schema such as 'interactive_pivot')
 
@@ -124,7 +124,7 @@ async def generate_chart(  # noqa: C901
     - chart_type='big_number' for single KPI metric displays.
       Required fields: metric
 
-    - chart_type='gauge_chart' for a dial/gauge display of a metric.
+    - chart_type='gauge' for a dial/gauge display of a metric.
       Required fields: metric; optional: groupby (one dial per value),
       min_val, max_val
 
@@ -150,7 +150,7 @@ async def generate_chart(  # noqa: C901
       only when get_chart_type_schema confirms it is available
     - "compare two metrics over time" -> chart_type='mixed_timeseries'
     - "single number" / "KPI" / "scorecard" -> chart_type='big_number'
-    - "gauge" / "dial" / "speedometer" -> chart_type='gauge_chart'
+    - "gauge" / "dial" / "speedometer" -> chart_type='gauge'
     - "custom HTML template" -> chart_type='handlebars'
     - "histogram" / "distribution" -> chart_type='histogram'
     - "box plot" / "box and whisker" -> chart_type='box_plot'
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index 77f4904c8c8..c88ea3db0c7 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -20,6 +20,7 @@ MCP tool: get_chart_data
 """
 
 import logging
+import math
 import time
 from typing import Any, Dict, List, TYPE_CHECKING
 
@@ -46,6 +47,9 @@ from superset.mcp_service.chart.chart_helpers import (
     rejected_requested_filter_columns,
 )
 from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.query_result import (
+    query_result_failure,
+)
 from superset.mcp_service.chart.schemas import (
     ChartData,
     ChartError,
@@ -518,6 +522,23 @@ async def get_chart_data(  # noqa: C901
             # The query_context contains all the information needed to reproduce
             # the chart's data exactly as shown in the visualization
             query_context_json = None
+            if using_unsaved_state and cached_form_data_dict is not None:
+                form_data = cached_form_data_dict
+            else:
+                try:
+                    parsed_saved_form_data = (
+                        utils_json.loads(chart.params) if chart.params else {}
+                    )
+                    form_data = (
+                        parsed_saved_form_data
+                        if isinstance(parsed_saved_form_data, dict)
+                        else {}
+                    )
+                except (TypeError, ValueError):
+                    form_data = {}
+
+            if not using_unsaved_state:
+                form_data["viz_type"] = chart.viz_type or form_data.get("viz_type")
 
             # If using cached form_data, we need to build query_context from it
             if using_unsaved_state and cached_form_data_dict is not None:
@@ -562,7 +583,6 @@ async def get_chart_data(  # noqa: C901
                     "Consider re-saving the chart to enable full data retrieval."
                 )
                 # Try to construct from form_data as a fallback
-                form_data = utils_json.loads(chart.params) if chart.params else {}
                 from superset.common.query_context_factory import QueryContextFactory
 
                 factory = QueryContextFactory()
@@ -689,6 +709,9 @@ async def get_chart_data(  # noqa: C901
                 command.validate()
                 result = command.run()
 
+            if query_failure := query_result_failure(result):
+                return query_failure
+
             if rejected := rejected_requested_filter_columns(
                 result, request.extra_form_data
             ):
@@ -1051,6 +1074,9 @@ async def _query_from_form_data(  # noqa: C901
             command.validate()
             result = command.run()
 
+        if query_failure := query_result_failure(result):
+            return query_failure
+
         if rejected := rejected_requested_filter_columns(
             result, request.extra_form_data
         ):
@@ -1115,6 +1141,23 @@ async def _query_from_form_data(  # noqa: C901
         )
 
         chart_name = form_data.get("slice_name", "Unsaved chart")
+        if request.format in {"csv", "excel"}:
+            from superset.models.slice import Slice
+
+            # A transient chart supplies export metadata without saving anything.
+            chart = Slice(id=0, slice_name=chart_name, viz_type=viz_type)
+            export = (
+                _export_data_as_csv
+                if request.format == "csv"
+                else _export_data_as_excel
+            )
+            return export(
+                chart,
+                data[: request.limit] if request.limit else data,
+                raw_columns,
+                cache_status,
+                PerformanceMetadata(query_duration_ms=0, cache_status="fresh_query"),
+            )
         summary = (
             f"Unsaved chart ({viz_type}). "
             f"Contains {len(data)} rows across {len(raw_columns)} columns."
@@ -1273,6 +1316,9 @@ def _write_excel_data(ws: Any, data: List[Dict[str, Any]], columns: List[str]) -
             value = row.get(col, "")
             if value is None:
                 value = ""
+            elif isinstance(value, float) and not math.isfinite(value):
+                # XLSX has no non-finite numbers; preserve them as CSV-style text.
+                value = str(value)
             elif isinstance(value, (list, dict)):
                 value = str(value)
             ws.cell(row=row_idx, column=col_idx, value=value)
@@ -1315,7 +1361,9 @@ def _create_excel_with_xlsxwriter(
     import xlsxwriter
 
     output = io.BytesIO()
-    workbook = xlsxwriter.Workbook(output, {"in_memory": True})
+    workbook = xlsxwriter.Workbook(
+        output, {"in_memory": True, "nan_inf_to_errors": True}
+    )
     sheet_name = chart.slice_name[:31] if chart.slice_name else "Chart Data"
     worksheet = workbook.add_worksheet(sheet_name)
 
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 37dfb5b27b7..9ed1c624173 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -39,6 +39,14 @@ from superset.mcp_service.chart.chart_helpers import (
     find_chart_by_identifier,
 )
 from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.preview_utils import (
+    generate_gauge_ascii_preview,
+    generate_gauge_vega_lite_preview,
+)
+from superset.mcp_service.chart.query_result import (
+    normalize_gauge_query_result,
+    query_result_failure,
+)
 from superset.mcp_service.chart.schemas import (
     AccessibilityMetadata,
     ASCIIPreview,
@@ -173,6 +181,14 @@ def _no_query_fields_error(chart: ChartLike) -> ChartError:
     )
 
 
+def _preview_row_limit(form_data: dict[str, Any], fallback: int) -> int:
+    """Keep Gauge preview cardinality aligned with its frontend row limit."""
+    if form_data.get("viz_type") != "gauge_chart":
+        return fallback
+    value = form_data.get("row_limit", 10)
+    return value if isinstance(value, int) and 1 <= value <= 10 else 10
+
+
 def _build_chart_description(chart: ChartLike) -> str:
     """Build a human-readable chart description, with hints for special chart types."""
     base = (
@@ -245,10 +261,11 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
                     error_type="InvalidChart",
                 )
 
+            form_data["viz_type"] = self.chart.viz_type or form_data.get("viz_type")
             query_context = build_query_context_from_form_data(
                 form_data,
                 chart=self.chart,
-                row_limit=50,
+                row_limit=_preview_row_limit(form_data, 50),
                 order_desc=True,
                 force=False,
             )
@@ -261,16 +278,29 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
             command.validate()
             result = command.run()
 
+            if query_failure := query_result_failure(result):
+                return query_failure
+            result = normalize_gauge_query_result(result, form_data)
+            if isinstance(result, ChartError):
+                return result
+
             data: list[Any] = []
             if result and "queries" in result and len(result["queries"]) > 0:
                 data = result["queries"][0].get("data") or []
 
-            ascii_chart = generate_ascii_chart(
-                data,
-                self.chart.viz_type or "table",
-                self.request.ascii_width or 80,
-                self.request.ascii_height or 20,
-            )
+            if form_data.get("viz_type") == "gauge_chart":
+                ascii_chart = generate_gauge_ascii_preview(
+                    data, form_data, self.request.ascii_width or 80
+                )
+                if isinstance(ascii_chart, ChartError):
+                    return ascii_chart
+            else:
+                ascii_chart = generate_ascii_chart(
+                    data,
+                    self.chart.viz_type or "table",
+                    self.request.ascii_width or 80,
+                    self.request.ascii_height or 20,
+                )
 
             return ASCIIPreview(
                 ascii_content=ascii_chart,
@@ -310,10 +340,11 @@ class TablePreviewStrategy(PreviewFormatStrategy):
                     error_type="InvalidChart",
                 )
 
+            form_data["viz_type"] = self.chart.viz_type or form_data.get("viz_type")
             query_context = build_query_context_from_form_data(
                 form_data,
                 chart=self.chart,
-                row_limit=20,
+                row_limit=_preview_row_limit(form_data, 20),
                 order_desc=True,
                 force=False,
             )
@@ -326,6 +357,12 @@ class TablePreviewStrategy(PreviewFormatStrategy):
             command.validate()
             result = command.run()
 
+            if query_failure := query_result_failure(result):
+                return query_failure
+            result = normalize_gauge_query_result(result, form_data)
+            if isinstance(result, ChartError):
+                return result
+
             data: list[Any] = []
             if result and "queries" in result and len(result["queries"]) > 0:
                 data = result["queries"][0].get("data") or []
@@ -366,7 +403,7 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
         except (ValueError, TypeError):
             return None
 
-    def generate(self) -> VegaLitePreview | ChartError:
+    def generate(self) -> VegaLitePreview | ChartError:  # noqa: C901
         """Generate Vega-Lite JSON specification from chart data."""
         try:
             # Get chart data directly using the same logic as get_chart_data tool
@@ -404,10 +441,11 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
                     utils_json.loads(self.chart.params) if self.chart.params else {}
                 )
 
+            form_data["viz_type"] = self.chart.viz_type or form_data.get("viz_type")
             query_context = build_query_context_from_form_data(
                 form_data,
                 chart=self.chart,
-                row_limit=1000,
+                row_limit=_preview_row_limit(form_data, 1000),
                 order_desc=True,
                 force=self.request.force_refresh,
             )
@@ -418,11 +456,19 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
             command.validate()
             result = command.run()
 
+            if query_failure := query_result_failure(result):
+                return query_failure
+            result = normalize_gauge_query_result(result, form_data)
+            if isinstance(result, ChartError):
+                return result
+
             # Extract data from result
             chart_data = []
             if result and "queries" in result and len(result["queries"]) > 0:
                 chart_data = result["queries"][0].get("data", [])
 
+            if form_data.get("viz_type") == "gauge_chart":
+                return generate_gauge_vega_lite_preview(chart_data, form_data)
             if not chart_data or not isinstance(chart_data, list):
                 return ChartError(
                     error="No data available for Vega-Lite visualization",
@@ -514,7 +560,6 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
             "box_plot": ["box_plot"],
             "heatmap": ["heatmap", "heatmap_v2", "cal_heatmap"],
             "funnel": ["funnel"],
-            "gauge": ["gauge_chart"],
             "mixed": ["mixed_timeseries"],
             "table": ["table"],
         }
@@ -921,34 +966,6 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
             },
         }
 
-    def _gauge_chart_spec(
-        self, fields: List[str], field_types: Dict[str, str] | None = None
-    ) -> Dict[str, Any]:
-        """Create gauge chart using arc marks."""
-        value_field = fields[0] if fields else "value"
-
-        return {
-            "mark": {
-                "type": "arc",
-                "innerRadius": 50,
-                "outerRadius": 80,
-                "tooltip": True,
-            },
-            "encoding": {
-                "theta": {
-                    "field": value_field,
-                    "type": "quantitative",
-                    "scale": {"range": [0, 6.28]},
-                },
-                "color": {
-                    "field": value_field,
-                    "type": "quantitative",
-                    "scale": {"scheme": "redyellowgreen"},
-                },
-                "tooltip": [{"field": f, "type": "nominal"} for f in fields[:3]],
-            },
-        }
-
     def _mixed_chart_spec(
         self, fields: List[str], field_types: Dict[str, str] | None = None
     ) -> Dict[str, Any]:
diff --git a/superset/mcp_service/chart/tool/get_chart_type_schema.py b/superset/mcp_service/chart/tool/get_chart_type_schema.py
index a29c2c9e201..d3ed18a4baf 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -50,7 +50,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
     "xy": TypeAdapter(XYChartConfig),
     "table": TypeAdapter(TableChartConfig),
     "pie": TypeAdapter(PieChartConfig),
-    "gauge_chart": TypeAdapter(GaugeChartConfig),
+    "gauge": TypeAdapter(GaugeChartConfig),
     "pivot_table": TypeAdapter(PivotTableChartConfig),
     "interactive_pivot": TypeAdapter(InteractivePivotChartConfig),
     "mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
@@ -206,9 +206,9 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
             "show_total": True,
         },
     ],
-    "gauge_chart": [
+    "gauge": [
         {
-            "chart_type": "gauge_chart",
+            "chart_type": "gauge",
             "metric": {"name": "progress", "aggregate": "AVG"},
         },
     ],
@@ -222,6 +222,8 @@ def _get_chart_type_schema_impl(
     """Pure logic for chart type schema lookup — no auth, no decorators."""
     from superset.mcp_service.chart.registry import get_registry
 
+    if chart_type == "gauge_chart":
+        chart_type = "gauge"
     enabled_types = sorted(get_registry().all_types())
     adapter = _CHART_TYPE_ADAPTERS.get(chart_type)
     if adapter is None:
@@ -299,7 +301,7 @@ def get_chart_type_schema(
     for a chart configuration before calling generate_chart or update_chart.
 
     Valid chart_type values depend on the host deployment. Core types are xy,
-    table, pie, gauge_chart, pivot_table, mixed_timeseries, handlebars, big_number,
+    table, pie, gauge, pivot_table, mixed_timeseries, handlebars, big_number,
     histogram, box_plot, and waterfall. Deployments that enable an AG Grid
     pivot extension also expose interactive_pivot.
 
diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py
index cbc76165500..0bc76fb6ee6 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -40,12 +40,14 @@ from superset.mcp_service.chart.chart_utils import (
     analyze_chart_semantics,
     generate_chart_name,
     map_config_to_form_data,
+    merge_chart_form_data,
     merge_interactive_pivot_ui_config,
     merge_table_column_config,
 )
 from superset.mcp_service.chart.compile import validate_and_compile
 from superset.mcp_service.chart.schemas import (
     AccessibilityMetadata,
+    ChartConfig,
     ColumnRef,
     GenerateChartResponse,
     PerformanceMetadata,
@@ -196,19 +198,40 @@ def _append_table_columns(
 def _merge_replacement_config(
     existing_form_data: dict[str, Any],
     new_form_data: dict[str, Any],
-    parsed_config: Any,
+    parsed_config: ChartConfig,
+    *,
+    dataset_rebind: bool = False,
 ) -> dict[str, Any]:
-    """Merge a replacement config, honoring an explicit empty filter list."""
-    merged = {
-        **{
-            key: value
-            for key, value in existing_form_data.items()
-            if key != "column_config"
-        },
-        **new_form_data,
-    }
-    if getattr(parsed_config, "filters", None) == []:
-        merged.pop("adhoc_filters", None)
+    """Delegate update semantics to the shared form-data merge helper."""
+    return merge_chart_form_data(
+        existing_form_data,
+        new_form_data,
+        parsed_config,
+        dataset_rebind=dataset_rebind,
+    )
+
+
+def _build_replacement_form_data(
+    existing_form_data: dict[str, Any],
+    parsed_config: ChartConfig,
+    effective_dataset_id: int | None,
+    replacement_dataset_id: int | None = None,
+) -> dict[str, Any]:
+    """Map and merge a replacement config for preview and save paths."""
+    new_form_data = map_config_to_form_data(
+        parsed_config, dataset_id=effective_dataset_id
+    )
+    new_form_data.pop("_mcp_warnings", None)
+    merge_table_column_config(existing_form_data, new_form_data)
+    merge_interactive_pivot_ui_config(existing_form_data, new_form_data)
+    merged = _merge_replacement_config(
+        existing_form_data,
+        new_form_data,
+        parsed_config,
+        dataset_rebind=replacement_dataset_id is not None,
+    )
+    if replacement_dataset_id is not None:
+        merged["datasource"] = f"{replacement_dataset_id}__table"
     return merged
 
 
@@ -230,12 +253,16 @@ def _build_update_payload(
     )
 
     if parsed_config is not None:
-        new_form_data = map_config_to_form_data(
-            parsed_config, dataset_id=effective_dataset_id
+        new_form_data = _build_replacement_form_data(
+            _get_existing_form_data(chart),
+            parsed_config,
+            effective_dataset_id,
+            replacement_dataset_id=(
+                request.dataset_id
+                if request.dataset_id != getattr(chart, "datasource_id", None)
+                else None
+            ),
         )
-        new_form_data.pop("_mcp_warnings", None)
-        merge_table_column_config(_get_existing_form_data(chart), new_form_data)
-        merge_interactive_pivot_ui_config(_get_existing_form_data(chart), new_form_data)
 
         chart_name = (
             request.chart_name
@@ -312,16 +339,15 @@ def _build_preview_form_data(
     )
 
     if parsed_config is not None:
-        new_form_data = map_config_to_form_data(
-            parsed_config, dataset_id=effective_dataset_id
-        )
-        new_form_data.pop("_mcp_warnings", None)
-        merge_table_column_config(existing_form_data, new_form_data)
-        merge_interactive_pivot_ui_config(existing_form_data, new_form_data)
-        # In the preview, an explicit filters list, including [], replaces saved
-        # filters. An omitted filters field preserves them through the shallow merge.
-        merged = _merge_replacement_config(
-            existing_form_data, new_form_data, parsed_config
+        merged = _build_replacement_form_data(
+            existing_form_data,
+            parsed_config,
+            effective_dataset_id,
+            replacement_dataset_id=(
+                request.dataset_id
+                if request.dataset_id != getattr(chart, "datasource_id", None)
+                else None
+            ),
         )
     elif request.add_columns is not None:
         patched = _append_table_columns(existing_form_data, request.add_columns)
@@ -601,6 +627,21 @@ async def update_chart(  # noqa: C901
                 }
             )
 
+        if (
+            request.dataset_id is not None
+            and request.dataset_id != getattr(chart, "datasource_id", None)
+            and request.config is None
+            and getattr(chart, "viz_type", None) == "gauge_chart"
+        ):
+            return _validation_error_response(
+                message="Gauge dataset rebind requires a complete Gauge config.",
+                details=(
+                    "Provide chart_type='gauge' and a metric valid on the target "
+                    "dataset. This prevents stale metric, groupby, and filter roles "
+                    "from the previous dataset from being retained."
+                ),
+            )
+
         # Validate dataset access before allowing update.
         # check_chart_data_access is the centralized data-level
         # permission check that complements the class-level RBAC
@@ -719,6 +760,7 @@ async def update_chart(  # noqa: C901
             preview_or_error = _build_preview_form_data(request, chart, parsed_config)
             if isinstance(preview_or_error, GenerateChartResponse):
                 return preview_or_error
+            new_form_data = preview_or_error
 
             # Validate before caching the form_data — same rationale as above.
             if validation_config is not None:
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 29cdfc05531..ead15007f13 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -38,10 +38,9 @@ from superset.mcp_service.chart.chart_utils import (
     generate_chart_name,
     generate_explore_link,
     map_config_to_form_data,
-    MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+    merge_chart_form_data,
     merge_interactive_pivot_ui_config,
     merge_table_column_config,
-    NO_TIME_RANGE,
 )
 from superset.mcp_service.chart.compile import validate_and_compile
 from superset.mcp_service.chart.preview_utils import (
@@ -106,50 +105,6 @@ def _get_previous_form_data(form_data_key: str) -> dict[str, Any] | None:
     return None
 
 
-def _preserve_previous_adhoc_filters(
-    new_form_data: dict[str, Any], previous_form_data: dict[str, Any]
-) -> None:
-    """Preserve cached filters without dropping mapper-generated bindings."""
-    previous_filters = previous_form_data.get("adhoc_filters")
-    if not isinstance(previous_filters, list) or not previous_filters:
-        return
-
-    generated_filters = new_form_data.get("adhoc_filters", [])
-    previous_binding = previous_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
-    new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
-    merged_filters = [
-        filter_
-        for filter_ in previous_filters
-        if not (
-            previous_binding
-            and previous_binding != new_binding
-            and isinstance(filter_, dict)
-            and filter_.get("operator") == "TEMPORAL_RANGE"
-            and filter_.get("subject") == previous_binding
-            and filter_.get("comparator") == NO_TIME_RANGE
-        )
-    ]
-    for generated_filter in generated_filters:
-        if not isinstance(generated_filter, dict):
-            if generated_filter not in merged_filters:
-                merged_filters.append(generated_filter)
-            continue
-
-        is_same_filter = any(
-            isinstance(previous_filter, dict)
-            and previous_filter.get("clause") == generated_filter.get("clause")
-            and previous_filter.get("expressionType")
-            == generated_filter.get("expressionType")
-            and previous_filter.get("subject") == generated_filter.get("subject")
-            and previous_filter.get("operator") == generated_filter.get("operator")
-            for previous_filter in merged_filters
-        )
-        if not is_same_filter:
-            merged_filters.append(generated_filter)
-
-    new_form_data["adhoc_filters"] = merged_filters
-
-
 @tool(
     tags=["mutate"],
     class_permission_name="Chart",
@@ -216,6 +171,24 @@ def update_chart_preview(  # noqa: C901
                 }
 
         with event_logger.log_context(action="mcp.update_chart_preview.form_data"):
+            from superset.mcp_service.chart.validation.dataset_validator import (
+                build_dataset_context_from_orm,
+                DatasetValidator,
+                NORMALIZATION_EXCEPTIONS,
+            )
+
+            try:
+                config = DatasetValidator.normalize_column_names(
+                    config,
+                    request.dataset_id,
+                    dataset_context=build_dataset_context_from_orm(dataset),
+                )
+            except NORMALIZATION_EXCEPTIONS as ex:
+                logger.warning(
+                    "Column normalization failed for preview dataset %s: %s",
+                    request.dataset_id,
+                    ex,
+                )
             # Map the new config to form_data format
             # Pass dataset_id to enable column type checking
             new_form_data = map_config_to_form_data(
@@ -230,16 +203,23 @@ def update_chart_preview(  # noqa: C901
                 if previous_form_data is None:
                     warnings.append(INVALID_FORM_DATA_KEY_WARNING)
 
-            # Preserve adhoc filters from the previous cached form_data
-            # when the new config doesn't explicitly specify filters
-            if getattr(config, "filters", None) is None and previous_form_data:
-                _preserve_previous_adhoc_filters(
-                    new_form_data,
-                    previous_form_data,
-                )
             if previous_form_data:
                 merge_table_column_config(previous_form_data, new_form_data)
                 merge_interactive_pivot_ui_config(previous_form_data, new_form_data)
+                previous_datasource = str(
+                    previous_form_data.get("datasource")
+                    or previous_form_data.get("datasource_id")
+                    or ""
+                ).split("__", 1)[0]
+                dataset_rebind = bool(
+                    previous_datasource
+                ) and previous_datasource != str(dataset.id)
+                new_form_data = merge_chart_form_data(
+                    previous_form_data,
+                    new_form_data,
+                    config,
+                    dataset_rebind=dataset_rebind,
+                )
 
             # Tier-1 schema validation against the dataset (no DB roundtrip).
             # Runs AFTER the filter merge so filter columns are also validated.
@@ -271,7 +251,10 @@ def update_chart_preview(  # noqa: C901
                 }
 
             compile_result = validate_and_compile(
-                config, new_form_data, dataset, run_compile_check=False
+                config,
+                new_form_data,
+                dataset,
+                run_compile_check=config.chart_type == "gauge",
             )
             if not compile_result.success:
                 logger.warning(
@@ -390,6 +373,7 @@ def update_chart_preview(  # noqa: C901
             "semantics": semantics.model_dump() if semantics else None,
             "explore_url": explore_url,
             "form_data_key": new_form_data_key,
+            "form_data": new_form_data,
             "previous_form_data_key": request.form_data_key,  # For reference
             "warnings": warnings,
             "api_endpoints": {},  # No API endpoints for unsaved charts
diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py
index 45333e90aeb..7747dca4c0d 100644
--- a/superset/mcp_service/chart/validation/dataset_validator.py
+++ b/superset/mcp_service/chart/validation/dataset_validator.py
@@ -23,7 +23,7 @@ Validates that referenced columns exist in the dataset schema.
 import difflib
 import logging
 import re
-from collections.abc import Mapping
+from collections.abc import Iterable, Mapping
 from typing import Any, Dict, List, Tuple, TypeVar
 
 from superset.mcp_service.chart.schemas import (
@@ -46,6 +46,38 @@ _NUMERIC_TYPE_PATTERN = re.compile(
 )
 
 
+class AmbiguousDatasetReferenceError(ValueError):
+    """A non-exact reference matches multiple names that differ only by case."""
+
+    def __init__(self, name: str, matches: list[str], reference_kind: str) -> None:
+        self.name = name
+        self.matches = sorted(matches)
+        self.reference_kind = reference_kind
+        choices = ", ".join(repr(match) for match in self.matches)
+        super().__init__(
+            f"{reference_kind.capitalize()} reference {name!r} is ambiguous because "
+            f"the dataset contains names that differ only by case: {choices}. "
+            f"Use the exact {reference_kind} name."
+        )
+
+
+def resolve_dataset_reference(
+    name: str, candidates: Iterable[str], reference_kind: str
+) -> str | None:
+    """Resolve exact names first and reject ambiguous case-insensitive matches."""
+    candidate_names = list(candidates)
+    if name in candidate_names:
+        return name
+    matches = [
+        candidate
+        for candidate in candidate_names
+        if candidate.casefold() == name.casefold()
+    ]
+    if len(matches) > 1:
+        raise AmbiguousDatasetReferenceError(name, matches, reference_kind)
+    return matches[0] if matches else None
+
+
 def is_numeric_column(column: Mapping[str, Any]) -> bool:
     """Return whether dataset metadata identifies a numeric SQL column."""
     if column.get("is_numeric", False):
@@ -218,11 +250,19 @@ class DatasetValidator:
         if not temporal_column:
             return None
 
+        try:
+            resolved_name = resolve_dataset_reference(
+                temporal_column,
+                (column["name"] for column in dataset_context.available_columns),
+                "physical column",
+            )
+        except AmbiguousDatasetReferenceError as ex:
+            return DatasetValidator._build_ambiguous_reference_error(ex)
         matching_column = next(
             (
                 column
                 for column in dataset_context.available_columns
-                if column["name"].lower() == temporal_column.lower()
+                if column["name"] == resolved_name
             ),
             None,
         )
@@ -267,15 +307,12 @@ class DatasetValidator:
         emit ``SUM(sum_boys)`` as an ad-hoc SIMPLE metric, producing the
         broken-SQL pattern this validator is meant to prevent.
         """
-        column_names_lower = {
-            col["name"].lower() for col in dataset_context.available_columns
-        }
-        metric_names_lower = {
-            metric["name"].lower() for metric in dataset_context.available_metrics
-        }
+        column_names = [col["name"] for col in dataset_context.available_columns]
+        metric_names = [metric["name"] for metric in dataset_context.available_metrics]
 
         invalid_columns: List[ColumnRef] = []
         saved_metric_typo: List[ColumnRef] = []
+        ambiguous_references: list[AmbiguousDatasetReferenceError] = []
         for col_ref in column_refs:
             if col_ref.saved_metric:
                 continue
@@ -285,10 +322,24 @@ class DatasetValidator:
             if col_ref.name is None:
                 # Should be unreachable per validate_metric_shape; defensive.
                 continue
-            name_lower = col_ref.name.lower()
-            if name_lower in column_names_lower:
+            try:
+                resolved_column = resolve_dataset_reference(
+                    col_ref.name, column_names, "physical column"
+                )
+            except AmbiguousDatasetReferenceError as ex:
+                ambiguous_references.append(ex)
+                continue
+            if resolved_column is not None:
                 continue
-            if name_lower in metric_names_lower:
+            try:
+                resolved_metric = resolve_dataset_reference(
+                    col_ref.name, metric_names, "saved metric"
+                )
+            except AmbiguousDatasetReferenceError:
+                # This non-saved ref is still best explained by the tailored
+                # saved-metric hint rather than choosing one metric arbitrarily.
+                resolved_metric = metric_names[0] if metric_names else None
+            if resolved_metric is not None:
                 # Name matches a saved metric but the ref didn't opt into
                 # saved-metric resolution. Surface a tailored hint so the
                 # caller (typically an LLM) can flip ``saved_metric=true``.
@@ -296,6 +347,10 @@ class DatasetValidator:
             else:
                 invalid_columns.append(col_ref)
 
+        if ambiguous_references:
+            return DatasetValidator._build_ambiguous_reference_error(
+                ambiguous_references[0]
+            )
         if saved_metric_typo:
             return DatasetValidator._build_saved_metric_hint_error(saved_metric_typo)
 
@@ -316,6 +371,25 @@ class DatasetValidator:
             invalid_columns, suggestions_map, dataset_context
         )
 
+    @staticmethod
+    def _build_ambiguous_reference_error(
+        error: AmbiguousDatasetReferenceError,
+    ) -> ChartGenerationError:
+        """Build an actionable error for case-colliding dataset metadata."""
+        return ChartGenerationError(
+            error_type="ambiguous_dataset_reference",
+            message=(
+                f"{error.reference_kind.capitalize()} reference "
+                f"'{error.name}' is ambiguous"
+            ),
+            details=str(error),
+            suggestions=[
+                f"Use one exact name: {', '.join(error.matches)}",
+                "Use get_dataset_info to inspect exact dataset casing",
+            ],
+            error_code="AMBIGUOUS_DATASET_REFERENCE",
+        )
+
     @staticmethod
     def _build_saved_metric_hint_error(
         refs: List[ColumnRef],
@@ -404,19 +478,20 @@ class DatasetValidator:
 
     @staticmethod
     def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
-        """Check if column exists in dataset (case-insensitive)."""
-        column_lower = column_name.lower()
-
-        # Check regular columns
-        for col in dataset_context.available_columns:
-            if col["name"].lower() == column_lower:
-                return True
-
-        # Check metrics
-        for metric in dataset_context.available_metrics:
-            if metric["name"].lower() == column_lower:
+        """Check if a physical column or saved metric resolves unambiguously."""
+        for candidates, reference_kind in (
+            (dataset_context.available_columns, "physical column"),
+            (dataset_context.available_metrics, "saved metric"),
+        ):
+            if (
+                resolve_dataset_reference(
+                    column_name,
+                    (candidate["name"] for candidate in candidates),
+                    reference_kind,
+                )
+                is not None
+            ):
                 return True
-
         return False
 
     @staticmethod
@@ -438,20 +513,21 @@ class DatasetValidator:
             The canonical column name from the dataset, or the original name
             if no match is found.
         """
-        column_lower = column_name.lower()
-
-        # Check regular columns first
-        for col in dataset_context.available_columns:
-            if col["name"].lower() == column_lower:
-                return col["name"]
-
-        # Check metrics
-        for metric in dataset_context.available_metrics:
-            if metric["name"].lower() == column_lower:
-                return metric["name"]
-
-        # Return original if not found (validation should catch this case)
-        return column_name
+        resolved_column = resolve_dataset_reference(
+            column_name,
+            (col["name"] for col in dataset_context.available_columns),
+            "physical column",
+        )
+        if resolved_column is not None:
+            return resolved_column
+        return (
+            resolve_dataset_reference(
+                column_name,
+                (metric["name"] for metric in dataset_context.available_metrics),
+                "saved metric",
+            )
+            or column_name
+        )
 
     @staticmethod
     def get_canonical_metric_name(
@@ -466,11 +542,14 @@ class DatasetValidator:
         Returns the original name when no metric matches (validation catches
         the missing-metric case separately).
         """
-        metric_lower = metric_name.lower()
-        for metric in dataset_context.available_metrics:
-            if metric["name"].lower() == metric_lower:
-                return metric["name"]
-        return metric_name
+        return (
+            resolve_dataset_reference(
+                metric_name,
+                (metric["name"] for metric in dataset_context.available_metrics),
+                "saved metric",
+            )
+            or metric_name
+        )
 
     @staticmethod
     def normalize_filters(
@@ -640,15 +719,19 @@ class DatasetValidator:
         a regular column name marked as saved_metric would pass
         _column_exists (which checks both lists) but fail at query time.
         """
-        metric_names = {m["name"].lower() for m in dataset_context.available_metrics}
-        # ``saved_metric=True`` requires ``name`` per ColumnRef.validate_metric_shape.
-        invalid: list[str] = [
-            col_ref.name
-            for col_ref in column_refs
-            if col_ref.saved_metric
-            and col_ref.name is not None
-            and col_ref.name.lower() not in metric_names
-        ]
+        metric_names = [m["name"] for m in dataset_context.available_metrics]
+        invalid: list[str] = []
+        for col_ref in column_refs:
+            if not col_ref.saved_metric or col_ref.name is None:
+                continue
+            try:
+                resolved = resolve_dataset_reference(
+                    col_ref.name, metric_names, "saved metric"
+                )
+            except AmbiguousDatasetReferenceError as ex:
+                return DatasetValidator._build_ambiguous_reference_error(ex)
+            if resolved is None:
+                invalid.append(col_ref.name)
         if not invalid:
             return None
 
@@ -694,12 +777,23 @@ class DatasetValidator:
                 # Should be unreachable per validate_metric_shape; defensive.
                 continue
 
-            # Find column info
-            col_info = None
-            for col in dataset_context.available_columns:
-                if col["name"].lower() == col_ref.name.lower():
-                    col_info = col
-                    break
+            try:
+                resolved_name = resolve_dataset_reference(
+                    col_ref.name,
+                    (col["name"] for col in dataset_context.available_columns),
+                    "physical column",
+                )
+            except AmbiguousDatasetReferenceError as ex:
+                errors.append(DatasetValidator._build_ambiguous_reference_error(ex))
+                continue
+            col_info = next(
+                (
+                    col
+                    for col in dataset_context.available_columns
+                    if col["name"] == resolved_name
+                ),
+                None,
+            )
 
             if col_info:
                 # Check numeric aggregates on non-numeric columns.
diff --git a/superset/mcp_service/chart/validation/schema_validator.py b/superset/mcp_service/chart/validation/schema_validator.py
index bdd777593ad..940a022def9 100644
--- a/superset/mcp_service/chart/validation/schema_validator.py
+++ b/superset/mcp_service/chart/validation/schema_validator.py
@@ -26,6 +26,7 @@ from typing import Any, Dict, Tuple
 from pydantic import ValidationError as PydanticValidationError
 
 from superset.mcp_service.chart.schemas import (
+    _normalize_chart_request_input,
     GenerateChartRequest,
 )
 from superset.mcp_service.common.error_schemas import ChartGenerationError
@@ -79,6 +80,14 @@ class SchemaValidator:
                 error_code="INVALID_REQUEST_FORMAT",
             )
 
+        # Pre-validation runs before GenerateChartRequest's model validator,
+        # so apply the same public/native vocabulary adapter here. Copy both
+        # levels to avoid mutating the caller's request dictionary.
+        data = dict(data)
+        if isinstance(data.get("config"), dict):
+            data["config"] = dict(data["config"])
+        data = _normalize_chart_request_input(data)
+
         # Check for required top-level fields
         if "dataset_id" not in data:
             return False, ChartGenerationError(
@@ -131,6 +140,7 @@ class SchemaValidator:
                     "Add 'chart_type': 'xy' for line/bar/area/scatter charts",
                     "Add 'chart_type': 'table' for table visualizations",
                     "Add 'chart_type': 'pie' for pie or donut charts",
+                    "Add 'chart_type': 'gauge' for a gauge or dial",
                     "Add 'chart_type': 'pivot_table' for OSS pivot tables",
                     "Use 'interactive_pivot' only when schema discovery exposes it",
                     "Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
@@ -167,6 +177,7 @@ class SchemaValidator:
                     "Use 'chart_type': 'xy' for line, bar, area, or scatter charts",
                     "Use 'chart_type': 'table' for tabular data display",
                     "Use 'chart_type': 'pie' for pie or donut charts",
+                    "Use 'chart_type': 'gauge' for a gauge or dial",
                     "Use 'chart_type': 'pivot_table' for OSS pivot tables",
                     "Use 'interactive_pivot' only when schema discovery exposes it",
                     "Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py b/superset/mcp_service/explore/tool/generate_explore_link.py
index 178c95ea8ae..9d8656da848 100644
--- a/superset/mcp_service/explore/tool/generate_explore_link.py
+++ b/superset/mcp_service/explore/tool/generate_explore_link.py
@@ -280,7 +280,7 @@ async def generate_explore_link(
                 normalized_config,
                 form_data,
                 dataset,
-                run_compile_check=False,
+                run_compile_check=normalized_config.chart_type == "gauge",
             )
         if not compile_result.success:
             await ctx.warning(
diff --git a/tests/unit_tests/common/test_query_context_processor.py b/tests/unit_tests/common/test_query_context_processor.py
index ad964ee2540..613e2faf72b 100644
--- a/tests/unit_tests/common/test_query_context_processor.py
+++ b/tests/unit_tests/common/test_query_context_processor.py
@@ -49,8 +49,8 @@ def _wire_contribution_totals(mock_query_context: MagicMock) -> None:
     ``QueryContext`` owns the normalization and the processor delegates to it, so a
     bare MagicMock would return a value the processor cannot unpack.
     """
-    mock_query_context.prepare_contribution_totals.side_effect = (
-        lambda: normalize_contribution_totals(
+    mock_query_context.prepare_contribution_totals.side_effect = lambda: (
+        normalize_contribution_totals(
             mock_query_context.queries, mock_query_context.cache_values
         )
     )
@@ -2604,3 +2604,24 @@ def test_mark_force_executed_uses_per_query_nonce(processor, mock_query_context)
     cache_manager.data_cache.set.assert_called_once_with(
         "gtf-force-nonce:task-uuid:ck", 1, timeout=123
     )
+
+
+def test_gauge_json_metric_label_ignores_verbose_name(
+    processor: QueryContextProcessor, mock_query_context: MagicMock
+) -> None:
+    """Gauge JSON rows retain saved metric keys, unlike CSV/XLSX exports."""
+    from superset.mcp_service.chart.query_result import validate_gauge_query_result
+
+    mock_query_context.datasource.data = {"verbose_map": {"saved_sla": "SLA percent"}}
+    mock_query_context.result_format = ChartDataResultFormat.JSON
+    rows = processor.get_data(
+        pd.DataFrame({"saved_sla": [42]}), [GenericDataType.NUMERIC]
+    )
+    assert rows == [{"saved_sla": 42}]
+    assert (
+        validate_gauge_query_result(
+            {"queries": [{"data": rows}]},
+            {"viz_type": "gauge_chart", "metric": "saved_sla"},
+        )
+        is None
+    )
diff --git a/tests/unit_tests/connectors/sqla/models_test.py b/tests/unit_tests/connectors/sqla/models_test.py
index 48fd76d2ac7..fca6cf038e1 100644
--- a/tests/unit_tests/connectors/sqla/models_test.py
+++ b/tests/unit_tests/connectors/sqla/models_test.py
@@ -1953,3 +1953,51 @@ def test_count_distinct_calculated_column_is_parenthesized() -> None:
         f"COUNT_DISTINCT over a calculated column should parenthesize the "
         f"expression. Rendered: {rendered}"
     )
+
+
+def test_gauge_query_restores_long_sql_metric_label(mocker: MockerFixture) -> None:
+    """Engine-compatible SQL aliases do not replace the frontend result label."""
+    from collections.abc import Callable
+
+    from sqlalchemy import literal_column
+
+    from superset.mcp_service.chart.query_result import validate_gauge_query_result
+
+    table = _build_sqla_table_for_query(mocker, "SELECT 42 AS truncated")
+    expected = "SUM(" + "long_column_" * 200 + ")"
+    mocker.patch.object(
+        table.db_engine_spec, "make_label_compatible", return_value="truncated"
+    )
+    column = table.make_sqla_column_compatible(literal_column("42"), expected)
+    assert column.name == "truncated"
+    assert column.key == expected
+    mocker.patch.object(
+        table,
+        "get_query_str_extended",
+        return_value=mocker.MagicMock(
+            sql="SELECT 42 AS truncated", labels_expected=[column.key]
+        ),
+    )
+
+    def get_df(
+        sql: str,
+        catalog: str,
+        schema: str,
+        mutator: Callable[[pd.DataFrame], pd.DataFrame],
+    ) -> pd.DataFrame:
+        """Apply the real query result relabeling callback to driver output."""
+        return mutator(pd.DataFrame({"truncated": [42]}))
+
+    mocker.patch.object(table.database, "get_df", side_effect=get_df)
+    result = table.query(_query_obj())
+    assert list(result.df.columns) == [expected]
+    assert (
+        validate_gauge_query_result(
+            {"queries": [{"data": result.df.to_dict(orient="records")}]},
+            {
+                "viz_type": "gauge_chart",
+                "metric": {"expressionType": "SQL", "sqlExpression": expected},
+            },
+        )
+        is None
+    )
diff --git a/tests/unit_tests/mcp_service/chart/test_compile.py b/tests/unit_tests/mcp_service/chart/test_compile.py
index 60608f5e4b5..65440b8353d 100644
--- a/tests/unit_tests/mcp_service/chart/test_compile.py
+++ b/tests/unit_tests/mcp_service/chart/test_compile.py
@@ -465,6 +465,74 @@ class TestValidateAndCompileTier2:
         assert result.error_code == "DATASET_NOT_FOUND"
 
 
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+def test_compile_gauge_uses_shared_query_builder_and_skips_invalid_dials(
+    mock_build_query_context, mock_cmd_cls
+):
+    """Gauge compile matches frontend ordering and retains finite groups."""
+    from superset.mcp_service.chart.compile import _compile_chart
+
+    mock_build_query_context.return_value = Mock()
+    mock_cmd_cls.return_value.validate.return_value = None
+    mock_cmd_cls.return_value.run.return_value = {
+        "queries": [
+            {
+                "data": [
+                    {"team": "A", "AVG(num)": 10},
+                    {"team": "B", "AVG(num)": "not numeric"},
+                ]
+            }
+        ]
+    }
+    form_data = {
+        "viz_type": "gauge_chart",
+        "metric": {
+            "expressionType": "SIMPLE",
+            "aggregate": "AVG",
+            "column": {"column_name": "num"},
+            "label": "AVG(num)",
+        },
+        "groupby": ["team"],
+        "sort_by_metric": True,
+        "row_limit": 10,
+    }
+
+    result = _compile_chart(form_data, dataset_id=3)
+
+    assert result.success
+    assert result.row_count == 1
+    query_form_data = mock_build_query_context.call_args.args[0]
+    assert query_form_data["sort_by_metric"] is True
+    assert query_form_data["metric"] == form_data["metric"]
+    assert query_form_data["datasource"] == "3__table"
+    mock_build_query_context.assert_called_once_with(
+        query_form_data, row_limit=10, force=False
+    )
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+def test_compile_gauge_accepts_numeric_saved_metric_result(
+    mock_build_query_context, mock_cmd_cls
+):
+    """Saved/SQL metrics stay supported when their concrete output is numeric."""
+    from superset.mcp_service.chart.compile import _compile_chart
+
+    mock_build_query_context.return_value = Mock()
+    mock_cmd_cls.return_value.validate.return_value = None
+    mock_cmd_cls.return_value.run.return_value = {
+        "queries": [{"data": [{"saved_sla": 99.5}]}]
+    }
+
+    result = _compile_chart(
+        {"viz_type": "gauge_chart", "metric": "saved_sla"}, dataset_id=3
+    )
+
+    assert result.success
+    assert result.row_count == 1
+
+
 @patch("superset.daos.dataset.DatasetDAO")
 @patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
 @patch("superset.common.query_context_factory.QueryContextFactory")
@@ -572,3 +640,44 @@ def test_valid_configs_pass_tier1(config_factory):
     ds = _orm_dataset()
     result = validate_and_compile(config_factory(), {}, ds, run_compile_check=False)
     assert result.success, result.error
+
+
+@pytest.mark.parametrize("clause", ["WHERE", "HAVING"])
+@pytest.mark.parametrize("subject", ["score", "Score", "SCORE"])
+def test_preserved_filter_ambiguity_is_actionable(clause: str, subject: str) -> None:
+    """The public compiler rejects ambiguous preserved filters, exact-first."""
+    dataset = _orm_dataset(column_names=["gender", "Score", "SCORE"])
+    config = TableChartConfig(chart_type="table", columns=[ColumnRef(name="gender")])
+    form_data = {
+        "adhoc_filters": [
+            {
+                "expressionType": "SIMPLE",
+                "clause": clause,
+                "subject": subject,
+                "operator": ">",
+                "comparator": 0,
+            }
+        ]
+    }
+    result = validate_and_compile(config, form_data, dataset, run_compile_check=False)
+    assert result.success is (subject != "score")
+    if subject == "score":
+        assert result.error_obj is not None
+        assert result.error_obj.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+        assert "Score" in result.error_obj.details
+        assert "SCORE" in result.error_obj.details
+
+
+def test_aggregation_ambiguity_returns_validation_errors() -> None:
+    """Direct aggregation validation has the same structured ambiguity contract."""
+    from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
+
+    context = build_dataset_context_from_orm(
+        _orm_dataset(column_names=["Score", "SCORE"])
+    )
+    assert context is not None
+    errors = DatasetValidator._validate_aggregations(
+        [ColumnRef(name="score", aggregate="AVG")], context
+    )
+    assert len(errors) == 1
+    assert errors[0].error_code == "AMBIGUOUS_DATASET_REFERENCE"
diff --git a/tests/unit_tests/mcp_service/chart/test_gauge_chart.py b/tests/unit_tests/mcp_service/chart/test_gauge_chart.py
index cabf97ac437..93c6890aba3 100644
--- a/tests/unit_tests/mcp_service/chart/test_gauge_chart.py
+++ b/tests/unit_tests/mcp_service/chart/test_gauge_chart.py
@@ -22,11 +22,33 @@ contract for viz_type ``gauge_chart`` — a single ``metric`` with an optional
 multi ``groupby`` producing one dial per row), and registry integration.
 """
 
+from collections.abc import Callable
+from typing import Any
+
 import pytest
 from pydantic import TypeAdapter, ValidationError
 
-from superset.mcp_service.chart.chart_utils import map_gauge_config
-from superset.mcp_service.chart.schemas import ChartConfig, GaugeChartConfig
+from superset.mcp_service.chart.chart_utils import (
+    map_config_to_form_data,
+    map_gauge_config,
+    MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+    merge_chart_form_data,
+)
+from superset.mcp_service.chart.preview_utils import (
+    generate_gauge_ascii_preview,
+    generate_gauge_vega_lite_preview,
+)
+from superset.mcp_service.chart.query_result import (
+    metric_result_label,
+    validate_gauge_query_result,
+)
+from superset.mcp_service.chart.schemas import (
+    ChartConfig,
+    ChartError,
+    GaugeChartConfig,
+    GenerateChartRequest,
+)
+from superset.mcp_service.common.error_schemas import DatasetContext
 
 
 class TestGaugeChartConfigSchema:
@@ -34,7 +56,7 @@ class TestGaugeChartConfigSchema:
 
     def test_basic_gauge_config(self) -> None:
         config = GaugeChartConfig(
-            chart_type="gauge_chart",
+            chart_type="gauge",
             metric={"name": "progress", "aggregate": "AVG"},
         )
         assert config.metric.name == "progress"
@@ -45,12 +67,12 @@ class TestGaugeChartConfigSchema:
 
     def test_gauge_missing_metric(self) -> None:
         with pytest.raises(ValidationError):
-            GaugeChartConfig(chart_type="gauge_chart", groupby=[{"name": "team"}])
+            GaugeChartConfig(chart_type="gauge", groupby=[{"name": "team"}])
 
     def test_gauge_rejects_extra_fields(self) -> None:
         with pytest.raises(ValidationError):
             GaugeChartConfig(
-                chart_type="gauge_chart",
+                chart_type="gauge",
                 metric={"name": "progress", "aggregate": "AVG"},
                 bogus=1,
             )
@@ -59,7 +81,7 @@ class TestGaugeChartConfigSchema:
         """A groupby dial dimension is a category, not a metric."""
         with pytest.raises(ValidationError):
             GaugeChartConfig(
-                chart_type="gauge_chart",
+                chart_type="gauge",
                 metric={"name": "progress", "aggregate": "AVG"},
                 groupby=[{"name": "count", "saved_metric": True}],
             )
@@ -68,7 +90,7 @@ class TestGaugeChartConfigSchema:
         """An aggregate makes a dial dimension metric-like; reject it."""
         with pytest.raises(ValidationError):
             GaugeChartConfig(
-                chart_type="gauge_chart",
+                chart_type="gauge",
                 metric={"name": "progress", "aggregate": "AVG"},
                 groupby=[{"name": "team", "aggregate": "SUM"}],
             )
@@ -77,7 +99,7 @@ class TestGaugeChartConfigSchema:
         """min_val >= max_val yields an inverted dial; reject it."""
         with pytest.raises(ValidationError):
             GaugeChartConfig(
-                chart_type="gauge_chart",
+                chart_type="gauge",
                 metric={"name": "progress", "aggregate": "AVG"},
                 min_val=100,
                 max_val=0,
@@ -87,7 +109,7 @@ class TestGaugeChartConfigSchema:
         """The frontend limits gauge dials to 10."""
         with pytest.raises(ValidationError):
             GaugeChartConfig(
-                chart_type="gauge_chart",
+                chart_type="gauge",
                 metric={"name": "progress", "aggregate": "AVG"},
                 row_limit=25,
             )
@@ -95,19 +117,160 @@ class TestGaugeChartConfigSchema:
     def test_chart_config_union_dispatches_gauge(self) -> None:
         config = TypeAdapter(ChartConfig).validate_python(
             {
-                "chart_type": "gauge_chart",
+                "chart_type": "gauge",
                 "metric": {"name": "progress", "aggregate": "AVG"},
             }
         )
         assert isinstance(config, GaugeChartConfig)
 
+    def test_public_union_rejects_native_viz_type_as_discriminator(self) -> None:
+        with pytest.raises(ValidationError):
+            TypeAdapter(ChartConfig).validate_python(
+                {
+                    "chart_type": "gauge_chart",
+                    "metric": {"name": "progress", "aggregate": "AVG"},
+                }
+            )
+
+    def test_request_aliases_native_gauge_form_data(self) -> None:
+        request = GenerateChartRequest.model_validate(
+            {
+                "datasource_id": 7,
+                "config": {
+                    "viz_type": "gauge_chart",
+                    "metric": "saved_progress",
+                    "groupby": ["team"],
+                },
+            }
+        )
+        assert request.dataset_id == 7
+        assert request.config.chart_type == "gauge"
+        assert request.config.metric.saved_metric is True
+
+    def test_native_form_data_full_control_surface_round_trips(self) -> None:
+        native = {
+            "viz_type": "gauge_chart",
+            "datasource": "7__table",
+            "slice_id": 11,
+            "metric": {
+                "expressionType": "SQL",
+                "sqlExpression": "AVG(progress)",
+                "label": "SLA",
+            },
+            "groupby": ["team", {"name": "region"}],
+            "sort_by_metric": False,
+            "row_limit": 5,
+            "min_val": 0,
+            "max_val": 100,
+            "color_scheme": "lyftColors",
+            "font_size": 18,
+            "number_format": ",.1f",
+            "currency_format": {"symbol": "%", "symbolPosition": "suffix"},
+            "value_formatter": "Progress: {value}",
+            "start_angle": 180,
+            "end_angle": 0,
+            "show_pointer": False,
+            "animation": False,
+            "show_axis_tick": True,
+            "show_split_line": True,
+            "split_number": 8,
+            "show_progress": False,
+            "overlap": False,
+            "round_cap": True,
+            "intervals": "50,80,100",
+            "interval_color_indices": "1,3,6",
+            "time_range": "Last week",
+            "granularity_sqla": "event_time",
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "country",
+                    "operator": "==",
+                    "comparator": "US",
+                }
+            ],
+        }
+        config = GaugeChartConfig.model_validate(native)
+        mapped = map_gauge_config(config)
+        for field in (
+            "groupby",
+            "sort_by_metric",
+            "row_limit",
+            "min_val",
+            "max_val",
+            "color_scheme",
+            "font_size",
+            "number_format",
+            "currency_format",
+            "value_formatter",
+            "start_angle",
+            "end_angle",
+            "show_pointer",
+            "animation",
+            "show_axis_tick",
+            "show_split_line",
+            "split_number",
+            "show_progress",
+            "overlap",
+            "round_cap",
+            "intervals",
+            "interval_color_indices",
+            "time_range",
+            "granularity_sqla",
+        ):
+            expected = ["team", "region"] if field == "groupby" else native[field]
+            assert mapped[field] == expected
+        assert mapped["metric"]["sqlExpression"] == "AVG(progress)"
+        assert mapped["adhoc_filters"][0]["operator"] == "=="
+
+    @pytest.mark.parametrize(
+        "metric",
+        [
+            {"name": "progress"},
+            {"name": "progress", "aggregate": None},
+        ],
+    )
+    def test_gauge_rejects_unaggregated_metric(self, metric: dict[str, Any]) -> None:
+        with pytest.raises(ValidationError, match="metric must define"):
+            GaugeChartConfig(chart_type="gauge", metric=metric)
+
+    def test_gauge_rejects_duplicate_casefold_groupby(self) -> None:
+        with pytest.raises(ValidationError, match="duplicates"):
+            GaugeChartConfig(
+                chart_type="gauge",
+                metric={"name": "progress", "aggregate": "AVG"},
+                groupby=[{"name": "Team"}, {"name": "team"}],
+            )
+
+    @pytest.mark.parametrize(
+        ("field", "value"),
+        [("min_val", float("nan")), ("max_val", float("inf"))],
+    )
+    def test_gauge_rejects_nonfinite_bounds(self, field: str, value: float) -> None:
+        with pytest.raises(ValidationError):
+            GaugeChartConfig(
+                chart_type="gauge",
+                metric={"name": "progress", "aggregate": "AVG"},
+                **{field: value},
+            )
+
+    def test_gauge_rejects_misaligned_intervals(self) -> None:
+        with pytest.raises(ValidationError, match="same length"):
+            GaugeChartConfig(
+                chart_type="gauge",
+                metric={"name": "progress", "aggregate": "AVG"},
+                intervals="50,100",
+                interval_color_indices="1",
+            )
+
 
 class TestMapGaugeConfig:
     """form_data mapping must match the frontend Gauge buildQuery."""
 
     def test_basic_gauge_form_data(self) -> None:
         config = GaugeChartConfig(
-            chart_type="gauge_chart",
+            chart_type="gauge",
             metric={"name": "progress", "aggregate": "AVG"},
         )
         form_data = map_gauge_config(config)
@@ -118,7 +281,7 @@ class TestMapGaugeConfig:
 
     def test_gauge_form_data_with_dials_and_range(self) -> None:
         config = GaugeChartConfig(
-            chart_type="gauge_chart",
+            chart_type="gauge",
             metric={"name": "progress", "aggregate": "AVG"},
             groupby=[{"name": "team"}],
             min_val=0,
@@ -134,7 +297,7 @@ class TestMapGaugeConfig:
     def test_gauge_color_scheme_defaults_and_bounds_emitted(self) -> None:
         """color_scheme defaults to supersetColors; min/max keys are emitted."""
         config = GaugeChartConfig(
-            chart_type="gauge_chart",
+            chart_type="gauge",
             metric={"name": "progress", "aggregate": "AVG"},
             min_val=0,
             max_val=100,
@@ -146,7 +309,7 @@ class TestMapGaugeConfig:
         # explicit scheme passes through unchanged
         explicit = map_gauge_config(
             GaugeChartConfig(
-                chart_type="gauge_chart",
+                chart_type="gauge",
                 metric={"name": "progress", "aggregate": "AVG"},
                 color_scheme="lyftColors",
             )
@@ -163,7 +326,7 @@ class TestMapGaugeConfig:
             lambda datasource_id, datasource_type: "base",
         )
         config = GaugeChartConfig(
-            chart_type="gauge_chart",
+            chart_type="gauge",
             metric={"name": "progress", "aggregate": "AVG"},
             groupby=[{"name": "team"}],
         )
@@ -176,7 +339,7 @@ class TestMapGaugeConfig:
 
     def test_gauge_saved_metric_maps_to_name_string(self) -> None:
         config = GaugeChartConfig(
-            chart_type="gauge_chart",
+            chart_type="gauge",
             metric={"name": "sla_attainment", "saved_metric": True},
         )
         assert map_gauge_config(config)["metric"] == "sla_attainment"
@@ -188,7 +351,7 @@ class TestGaugePluginRegistry:
     def test_gauge_plugin_registered(self) -> None:
         from superset.mcp_service.chart import registry
 
-        plugin = registry.get("gauge_chart")
+        plugin = registry.get("gauge")
         assert plugin is not None
         assert plugin.resolve_viz_type(None) == "gauge_chart"
 
@@ -200,9 +363,9 @@ class TestGaugePluginRegistry:
     def test_pre_validate_missing_metric(self) -> None:
         from superset.mcp_service.chart import registry
 
-        plugin = registry.get("gauge_chart")
+        plugin = registry.get("gauge")
         assert plugin is not None
-        error = plugin.pre_validate({"chart_type": "gauge_chart"})
+        error = plugin.pre_validate({"chart_type": "gauge"})
         assert error is not None
         assert "metric" in error.message
 
@@ -210,9 +373,9 @@ class TestGaugePluginRegistry:
         """groupby is optional; metric alone is a valid gauge."""
         from superset.mcp_service.chart import registry
 
-        plugin = registry.get("gauge_chart")
+        plugin = registry.get("gauge")
         assert plugin is not None
-        assert plugin.pre_validate({"chart_type": "gauge_chart", "metric": {}}) is None
+        assert plugin.pre_validate({"chart_type": "gauge", "metric": {}}) is None
 
 
 class TestGaugeRecommendationCategory:
@@ -228,4 +391,791 @@ class TestGaugeRecommendationCategory:
             _CHART_TYPE_ADAPTERS,
         )
 
-        assert "gauge_chart" in _CHART_TYPE_ADAPTERS
+        assert "gauge" in _CHART_TYPE_ADAPTERS
+
+    def test_chart_config_resource_uses_public_gauge_identity(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        from unittest.mock import Mock
+
+        from superset.mcp_service.chart.resources.chart_configs import (
+            get_chart_configs_resource,
+        )
+        from superset.utils import json
+
+        monkeypatch.setattr(
+            "superset.mcp_service.auth.get_user_from_request",
+            lambda: Mock(id=1, username="admin"),
+        )
+        resource = json.loads(get_chart_configs_resource())
+        example = resource["gauge_configs"]["attainment_gauge"]["config"]
+        assert example["chart_type"] == "gauge"
+        assert "gauge_charts" in resource["best_practices"]
+
+
+class TestGaugeUpdateRoundTrip:
+    """Gauge updates preserve omissions but honor explicit clears."""
+
+    @staticmethod
+    def existing_form_data() -> dict[str, Any]:
+        return {
+            "viz_type": "gauge_chart",
+            "datasource": "1__table",
+            "metric": "saved_progress",
+            "groupby": ["team"],
+            "row_limit": 4,
+            "sort_by_metric": False,
+            "min_val": 0,
+            "max_val": 100,
+            "font_size": 19,
+            "number_format": ",.1f",
+            "currency_format": {"symbol": "%", "symbolPosition": "suffix"},
+            "value_formatter": "{value} complete",
+            "show_pointer": False,
+            "show_progress": False,
+            "intervals": "50,100",
+            "interval_color_indices": "2,4",
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "country",
+                    "operator": "==",
+                    "comparator": "US",
+                }
+            ],
+        }
+
+    def test_same_viz_omissions_preserve_native_controls(self) -> None:
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "progress", "aggregate": "AVG"},
+        )
+        merged = merge_chart_form_data(
+            self.existing_form_data(), map_gauge_config(config), config
+        )
+        assert merged["metric"]["label"] == "AVG(progress)"
+        assert merged["groupby"] == ["team"]
+        assert merged["row_limit"] == 4
+        assert merged["font_size"] == 19
+        assert merged["currency_format"]["symbol"] == "%"
+        assert merged["adhoc_filters"][0]["subject"] == "country"
+
+    def test_explicit_clears_clear_optional_controls(self) -> None:
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "progress", "aggregate": "AVG"},
+            groupby=[],
+            filters=[],
+            color_scheme=None,
+            currency_format=None,
+            time_range=None,
+            granularity_sqla=None,
+            intervals="",
+            interval_color_indices="",
+        )
+        merged = merge_chart_form_data(
+            self.existing_form_data(), map_gauge_config(config), config
+        )
+        assert merged["groupby"] == []
+        assert "adhoc_filters" not in merged
+        assert "color_scheme" not in merged
+        assert "currency_format" not in merged
+        assert merged["intervals"] == ""
+
+    def test_dataset_rebind_scrubs_roles_and_preserves_presentation(self) -> None:
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "new_progress", "aggregate": "AVG"},
+        )
+        merged = merge_chart_form_data(
+            self.existing_form_data(),
+            map_gauge_config(config),
+            config,
+            dataset_rebind=True,
+        )
+        assert merged["metric"]["label"] == "AVG(new_progress)"
+        assert "groupby" not in merged
+        assert "adhoc_filters" not in merged
+        assert merged["font_size"] == 19
+        assert merged["number_format"] == ",.1f"
+
+    def test_filter_clear_keeps_explicit_temporal_binding(self) -> None:
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "progress", "aggregate": "AVG"},
+            filters=[],
+            temporal_column="event_time",
+        )
+        mapped = map_config_to_form_data(config)
+        merged = merge_chart_form_data(self.existing_form_data(), mapped, config)
+        assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "event_time"
+        assert merged["adhoc_filters"] == [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "event_time",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "No filter",
+            }
+        ]
+
+    def test_cross_viz_does_not_inherit_gauge_controls(self) -> None:
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "progress", "aggregate": "AVG"},
+        )
+        existing = {**self.existing_form_data(), "viz_type": "pie", "donut": True}
+        merged = merge_chart_form_data(existing, map_gauge_config(config), config)
+        assert "donut" not in merged
+        assert merged["viz_type"] == "gauge_chart"
+
+
+class TestGaugeResultAndPreview:
+    """Gauge data and previews share exact frontend metric-label semantics."""
+
+    @staticmethod
+    def form_data() -> dict[str, Any]:
+        return {
+            "viz_type": "gauge_chart",
+            "metric": {
+                "expressionType": "SIMPLE",
+                "aggregate": "AVG",
+                "column": {"column_name": "progress"},
+                "label": "AVG(progress)",
+            },
+            "groupby": ["team"],
+            "min_val": 0,
+            "max_val": 100,
+            "number_format": ",.1f",
+            "value_formatter": "{value} complete",
+            "intervals": "50,100",
+            "interval_color_indices": "1,4",
+        }
+
+    @pytest.mark.parametrize(
+        ("metric", "expected"),
+        [
+            ("saved_progress", "saved_progress"),
+            (
+                {
+                    "expressionType": "SIMPLE",
+                    "aggregate": "AVG",
+                    "column": {"columnName": "progress"},
+                    "label": None,
+                },
+                "AVG(progress)",
+            ),
+            (
+                {
+                    "expressionType": "SQL",
+                    "sqlExpression": "AVG(progress)",
+                    "label": "SLA",
+                },
+                "SLA",
+            ),
+        ],
+    )
+    def test_metric_result_aliases(self, metric: object, expected: str) -> None:
+        assert metric_result_label(metric) == expected
+
+    @pytest.mark.parametrize("bad_value", ["12", True, float("nan"), float("inf")])
+    def test_rejects_non_numeric_or_nonfinite_results(self, bad_value: object) -> None:
+        failure = validate_gauge_query_result(
+            {"queries": [{"data": [{"AVG(progress)": bad_value}]}]},
+            self.form_data(),
+        )
+        assert isinstance(failure, ChartError)
+
+    def test_rejects_errors_and_missing_metric_alias(self) -> None:
+        query_error = validate_gauge_query_result(
+            {"queries": [{"error": "database exploded", "data": []}]},
+            self.form_data(),
+        )
+        assert isinstance(query_error, ChartError)
+        assert "database exploded" in query_error.error
+        missing = validate_gauge_query_result(
+            {"queries": [{"data": [{"wrong": 12}]}]}, self.form_data()
+        )
+        assert isinstance(missing, ChartError)
+        assert "missing metric output" in missing.error
+
+    def test_ascii_preview_is_gauge_faithful_and_formatted(self) -> None:
+        preview = generate_gauge_ascii_preview(
+            [{"team": "Blue", "AVG(progress)": 67.25}], self.form_data()
+        )
+        assert isinstance(preview, str)
+        assert "Gauge Chart" in preview
+        assert "Range: 0 to 100" in preview
+        assert "Intervals: 50, 100" in preview
+        assert "Blue" in preview
+        assert "67.3 complete" in preview
+
+    def test_vega_preview_uses_radial_layers_and_controls(self) -> None:
+        form_data = {
+            **self.form_data(),
+            "show_progress": False,
+            "show_pointer": True,
+            "start_angle": 180,
+            "end_angle": 0,
+        }
+        preview = generate_gauge_vega_lite_preview(
+            [{"team": "Blue", "AVG(progress)": 67.25}], form_data
+        )
+        assert not isinstance(preview, ChartError)
+        spec = preview.specification
+        assert spec["usermeta"]["viz_type"] == "gauge_chart"
+        assert spec["columns"] == 1
+        marks = [layer["mark"]["type"] for layer in spec["spec"]["layer"]]
+        assert marks.count("arc") == 2  # two colored interval bands, no progress
+        assert "rule" in marks  # pointer
+        assert "text" in marks
+
+    def test_empty_preview_is_safe(self) -> None:
+        ascii_preview = generate_gauge_ascii_preview([], self.form_data())
+        assert isinstance(ascii_preview, str)
+        assert "No data available" in ascii_preview
+        vega_preview = generate_gauge_vega_lite_preview([], self.form_data())
+        assert not isinstance(vega_preview, ChartError)
+        assert vega_preview.specification["data"]["values"] == []
+
+
+class TestGaugeDatasetValidation:
+    """Dataset resolution is exact-first and never guesses on collisions."""
+
+    @staticmethod
+    def context() -> DatasetContext:
+        return DatasetContext(
+            id=1,
+            table_name="metrics",
+            schema="public",
+            database_name="db",
+            available_columns=[
+                {"name": "Score", "type": "DOUBLE", "is_numeric": True},
+                {"name": "score", "type": "VARCHAR", "is_numeric": False},
+                {"name": "Team", "type": "VARCHAR", "is_numeric": False},
+            ],
+            available_metrics=[
+                {"name": "SLA", "expression": "AVG(Score)"},
+                {"name": "sla", "expression": "AVG(Score)"},
+            ],
+        )
+
+    def test_exact_case_wins_and_unique_casefold_normalizes(self) -> None:
+        from superset.mcp_service.chart.validation.dataset_validator import (
+            DatasetValidator,
+        )
+
+        context = self.context()
+        assert DatasetValidator.get_canonical_column_name("Score", context) == "Score"
+        assert DatasetValidator.get_canonical_column_name("team", context) == "Team"
+
+    def test_ambiguous_nonexact_column_and_metric_are_rejected(self) -> None:
+        from superset.mcp_service.chart.validation.dataset_validator import (
+            AmbiguousDatasetReferenceError,
+            DatasetValidator,
+        )
+
+        context = self.context()
+        with pytest.raises(AmbiguousDatasetReferenceError):
+            DatasetValidator.get_canonical_column_name("SCORE", context)
+        with pytest.raises(AmbiguousDatasetReferenceError):
+            DatasetValidator.get_canonical_metric_name("sLa", context)
+
+    def test_dataset_validation_surfaces_ambiguity(self) -> None:
+        from superset.mcp_service.chart.validation.dataset_validator import (
+            DatasetValidator,
+        )
+
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "SCORE", "aggregate": "AVG"},
+        )
+        valid, error = DatasetValidator.validate_against_dataset(
+            config, 1, self.context()
+        )
+        assert valid is False
+        assert error is not None
+        assert error.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+
+    def test_non_numeric_simple_metric_rejected_but_count_allowed(
+        self, monkeypatch
+    ) -> None:
+        from superset.mcp_service.chart.plugins.gauge import GaugeChartPlugin
+        from superset.mcp_service.chart.validation.dataset_validator import (
+            DatasetValidator,
+        )
+
+        context = self.context()
+        monkeypatch.setattr(
+            DatasetValidator, "_get_dataset_context", lambda dataset_id: context
+        )
+        plugin = GaugeChartPlugin()
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "Team", "aggregate": "AVG"},
+        )
+        error = plugin.post_map_validate(config, map_gauge_config(config), 1)
+        assert error is not None
+        assert error.error_code == "NON_NUMERIC_GAUGE_METRIC"
+        count = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "Team", "aggregate": "COUNT"},
+        )
+        assert plugin.post_map_validate(count, map_gauge_config(count), 1) is None
+
+
+@pytest.mark.parametrize("value", [5, -5, 75])
+@pytest.mark.parametrize(
+    "preview_fn",
+    [
+        generate_gauge_ascii_preview,
+        generate_gauge_vega_lite_preview,
+    ],
+)
+def test_gauge_auto_bounds_accept_stable_intervals(
+    value: int,
+    preview_fn: Callable[..., Any],
+) -> None:
+    """Data-derived bounds must not invalidate stable configured thresholds."""
+    result = preview_fn(
+        [{"score": value}],
+        {
+            "metric": "score",
+            "intervals": "30,70,100",
+        },
+    )
+    assert not isinstance(result, ChartError)
+
+
+@pytest.mark.parametrize("bounds", [{"min_val": 40}, {"max_val": 90}])
+def test_gauge_explicit_bounds_still_reject_outside_intervals(
+    bounds: dict[str, int],
+) -> None:
+    """Only explicitly configured bounds constrain threshold validation."""
+    result = generate_gauge_ascii_preview(
+        [{"score": 5}],
+        {
+            "metric": "score",
+            "intervals": "30,70,100",
+            **bounds,
+        },
+    )
+    assert isinstance(result, ChartError)
+
+
+@pytest.mark.parametrize("angles", [(225, -45), (0, 180), (180, 0)])
+def test_gauge_vega_bands_span_adjacent_thresholds(angles: tuple[int, int]) -> None:
+    """Every background arc has explicit adjacent endpoints without stacking."""
+    result = generate_gauge_vega_lite_preview(
+        [{"score": 5}],
+        {
+            "metric": "score",
+            "min_val": 0,
+            "max_val": 100,
+            "intervals": "30,70,100",
+            "show_progress": False,
+            "start_angle": angles[0],
+            "end_angle": angles[1],
+        },
+    )
+    assert not isinstance(result, ChartError)
+    arcs = [
+        layer["encoding"]
+        for layer in result.specification["layer"]
+        if layer["mark"]["type"] == "arc"
+    ]
+    assert [(arc["theta"]["datum"], arc["theta2"]["datum"]) for arc in arcs] == [
+        (0, 0.3),
+        (0.3, 0.7),
+        (0.7, 1),
+    ]
+    assert all(arc["theta"]["stack"] is None for arc in arcs)
+
+
+def test_gauge_vega_clips_intervals_to_automatic_range() -> None:
+    """Off-dial bands are clipped rather than overlapping or rejecting data."""
+    result = generate_gauge_vega_lite_preview(
+        [{"score": 5}],
+        {
+            "metric": "score",
+            "intervals": "3,7,100",
+            "show_progress": False,
+        },
+    )
+    assert not isinstance(result, ChartError)
+    arcs = [
+        layer["encoding"]
+        for layer in result.specification["layer"]
+        if layer["mark"]["type"] == "arc"
+    ]
+    assert [(arc["theta"]["datum"], arc["theta2"]["datum"]) for arc in arcs] == [
+        (0, 0.3),
+        (0.3, 0.7),
+        (0.7, 1),
+    ]
+
+
+@pytest.mark.parametrize("comparator", [None, 123])
+def test_gauge_native_temporal_range_requires_comparator(comparator: Any) -> None:
+    """Native filters cannot silently discard a missing temporal restriction."""
+    with pytest.raises(ValidationError, match="requires a temporal comparator"):
+        GaugeChartConfig.model_validate(
+            {
+                "chart_type": "gauge",
+                "metric": "score",
+                "time_range": "Last week",
+                "adhoc_filters": [
+                    {
+                        "subject": "event_time",
+                        "operator": "TEMPORAL_RANGE",
+                        "comparator": comparator,
+                    }
+                ],
+            }
+        )
+
+
+@pytest.mark.parametrize("temporal_column", [None, "other_time"])
+@pytest.mark.parametrize("comparator", ["No filter", "Last month"])
+def test_gauge_temporal_rebind_preserves_user_ranges(
+    temporal_column: str | None,
+    comparator: str,
+) -> None:
+    """A stale provenance subject does not make an edited range mapper-owned."""
+    original_filter = {
+        "clause": "WHERE",
+        "expressionType": "SIMPLE",
+        "subject": "event_time",
+        "operator": "TEMPORAL_RANGE",
+        "comparator": comparator,
+    }
+    user_filter = {**original_filter, "comparator": "Last week"}
+    existing = {
+        "viz_type": "gauge_chart",
+        "metric": "score",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "event_time",
+        "adhoc_filters": [original_filter, user_filter],
+    }
+    config = GaugeChartConfig(
+        chart_type="gauge",
+        metric={"name": "score", "saved_metric": True},
+        temporal_column=temporal_column,
+    )
+    mapped = map_config_to_form_data(config)
+    merged = merge_chart_form_data(existing, mapped, config)
+    expected = (
+        [user_filter] if comparator == "No filter" else [original_filter, user_filter]
+    )
+    if temporal_column:
+        expected += mapped["adhoc_filters"]
+    assert merged["adhoc_filters"] == expected
+    assert existing["adhoc_filters"] == [original_filter, user_filter]
+
+
+@pytest.mark.parametrize(
+    "renderer", [generate_gauge_ascii_preview, generate_gauge_vega_lite_preview]
+)
+def test_gauge_all_zero_auto_range(renderer: Callable[..., Any]) -> None:
+    """Zero-valued results remain renderable without weakening explicit bounds."""
+    result = renderer([{"score": 0}], {"viz_type": "gauge_chart", "metric": "score"})
+    assert not isinstance(result, ChartError)
+    if isinstance(result, str):
+        assert "Range: 0 to 1" in result
+    else:
+        assert result.specification["usermeta"]["min_val"] == 0
+        assert result.specification["usermeta"]["max_val"] == 1
+    for bounds in ({"min_val": 0, "max_val": 0}, {"max_val": 0}):
+        invalid = renderer(
+            [{"score": 0}], {"viz_type": "gauge_chart", "metric": "score", **bounds}
+        )
+        assert isinstance(invalid, ChartError)
+        assert invalid.error_type == "InvalidGaugeRange"
+
+
+@pytest.mark.parametrize("viz_type", [None, "table"])
+@pytest.mark.parametrize(
+    "renderer", [generate_gauge_ascii_preview, generate_gauge_vega_lite_preview]
+)
+def test_gauge_renderer_always_validates(
+    viz_type: str | None, renderer: Callable[..., Any]
+) -> None:
+    """Direct Gauge rendering validates regardless of the caller's discriminator."""
+    for data, metric, error in (
+        ([{"score": "bad"}], "score", "NonNumericGaugeMetric"),
+        ({}, "score", "InvalidGaugeResult"),
+        ([{}], None, "InvalidGaugeFormData"),
+    ):
+        result = renderer(data, {"viz_type": viz_type, "metric": metric})
+        assert isinstance(result, ChartError)
+        assert result.error_type == error
+
+
+def test_gauge_metric_label_has_no_arbitrary_size_caps() -> None:
+    """Query output labels follow getMetricLabel, not incidental metadata size."""
+    from superset.utils.core import get_metric_name
+
+    metrics: list[Any] = [
+        {"expressionType": "SQL", "sqlExpression": "x + " * 600 + "1"},
+        {
+            "expressionType": "SIMPLE",
+            "aggregate": "AVG",
+            "column": {"column_name": "score"},
+            **{f"metadata_{i}": i for i in range(30)},
+        },
+        "saved_score",
+    ]
+    for metric in metrics:
+        label = get_metric_name(metric)
+        assert metric_result_label(metric) == label
+        assert (
+            validate_gauge_query_result(
+                {"queries": [{"data": [{label: 42}]}]},
+                {"viz_type": "gauge_chart", "metric": metric},
+            )
+            is None
+        )
+
+
+@pytest.mark.parametrize(
+    "second,top_level",
+    [
+        ({"subject": "other_time"}, {}),
+        ({"comparator": "Last month"}, {}),
+        (None, {"time_range": "Last month"}),
+        (None, {"granularity_sqla": "other_time"}),
+    ],
+)
+def test_gauge_native_temporal_conflicts_reject(
+    second: dict[str, str] | None, top_level: dict[str, str]
+) -> None:
+    """Native restrictions cannot disappear during typed normalization."""
+    temporal = {
+        "expressionType": "SIMPLE",
+        "clause": "WHERE",
+        "subject": "event_time",
+        "operator": "TEMPORAL_RANGE",
+        "comparator": "Last week",
+    }
+    with pytest.raises(ValidationError, match="conflicts with another temporal range"):
+        GaugeChartConfig.model_validate(
+            {
+                "viz_type": "gauge_chart",
+                "metric": "score",
+                "adhoc_filters": [temporal, {**temporal, **second}]
+                if second
+                else [temporal],
+                **top_level,
+            }
+        )
+
+
+@pytest.mark.parametrize(
+    "operator",
+    [
+        "NOT ILIKE",
+        "IS TRUE",
+        "IS FALSE",
+        "CONTAINS_ANY",
+        "CONTAINS_ALL",
+        "IS_EMPTY",
+        "IS_NOT_EMPTY",
+        "LENGTH_EQUALS",
+    ],
+)
+def test_gauge_unsupported_native_operator_has_indexed_error(operator: str) -> None:
+    """Unsupported native operators are rejected at the adaptation boundary."""
+    with pytest.raises(
+        ValidationError, match=r"adhoc_filters\[0\] uses unsupported operator"
+    ):
+        GaugeChartConfig.model_validate(
+            {
+                "viz_type": "gauge_chart",
+                "metric": "score",
+                "adhoc_filters": [
+                    {"subject": "team", "operator": operator, "comparator": "a"}
+                ],
+            }
+        )
+
+
+@pytest.mark.parametrize("groupby", [[], ["team"]])
+@pytest.mark.parametrize("show_pointer", [True, False])
+def test_gauge_pointer_uses_cartesian_endpoints(
+    groupby: list[str], show_pointer: bool
+) -> None:
+    """Vega-Lite rules need Cartesian endpoints even on a polar dial."""
+    preview = generate_gauge_vega_lite_preview(
+        [{"score": 50, "team": "Blue"}],
+        {
+            "metric": "score",
+            "groupby": groupby,
+            "show_pointer": show_pointer,
+            "min_val": 0,
+            "max_val": 100,
+        },
+    )
+    assert not isinstance(preview, ChartError)
+    specification = preview.specification
+    unit = specification["spec"] if groupby else specification
+    if groupby:
+        assert unit["width"] == 200
+        assert unit["height"] == 300
+        assert "width" not in specification
+    rules = [layer for layer in unit["layer"] if layer["mark"]["type"] == "rule"]
+    assert len(rules) == int(show_pointer)
+    if show_pointer:
+        encoding = rules[0]["encoding"]
+        assert set(encoding) == {"x", "y", "x2", "y2", "tooltip"}
+        if groupby:
+            # Numeric child-scoped origins must not depend on outer Vega signals.
+            assert float(encoding["x"]["value"]["expr"]) == unit["width"] / 2
+            assert float(encoding["y"]["value"]["expr"]) == unit["height"] / 2
+        else:
+            assert encoding["x"]["value"]["expr"] == "width / 2"
+            assert encoding["y"]["value"]["expr"] == "height / 2"
+        arc = next(
+            layer["mark"] for layer in unit["layer"] if layer["mark"]["type"] == "arc"
+        )
+        radius = (arc["innerRadius"] + arc["outerRadius"]) / 2
+        assert f"+ {radius} * sin" in encoding["x2"]["value"]["expr"]
+        assert f"- {radius} * cos" in encoding["y2"]["value"]["expr"]
+        assert "sin(datum.__mcp_gauge_angle)" in encoding["x2"]["value"]["expr"]
+        assert "cos(datum.__mcp_gauge_angle)" in encoding["y2"]["value"]["expr"]
+
+
+@pytest.mark.parametrize(
+    "renderer", [generate_gauge_ascii_preview, generate_gauge_vega_lite_preview]
+)
+def test_gauge_mixed_finite_dials_survive(renderer: Callable[..., Any]) -> None:
+    """Empty aggregates do not discard other groups, in either preview format."""
+    rows = [
+        {"team": str(i), "score": value}
+        for i, value in enumerate(
+            [None, float("nan"), float("inf"), -float("inf"), "bad", True, 0, 42]
+        )
+    ]
+    result = renderer(rows, {"metric": "score", "groupby": ["team"]})
+    assert not isinstance(result, ChartError)
+    if renderer == generate_gauge_vega_lite_preview:
+        assert [r["score"] for r in result.specification["data"]["values"]] == [0, 42]
+    else:
+        assert "42" in result
+        assert "nan" not in result.lower()
+    assert len(rows) == 8
+
+
+@pytest.mark.parametrize("temporal_column", ["omitted", "event_time", None])
+def test_gauge_duplicate_generated_binding_self_heals(
+    temporal_column: str | None,
+) -> None:
+    """Updating a legacy Gauge removes redundant neutral filters, not user ranges."""
+    binding = {
+        "expressionType": "SIMPLE",
+        "clause": "WHERE",
+        "subject": "event_time",
+        "operator": "TEMPORAL_RANGE",
+        "comparator": "No filter",
+    }
+    config = GaugeChartConfig.model_validate(
+        {
+            "metric": {"name": "score", "saved_metric": True},
+            **(
+                {"temporal_column": temporal_column}
+                if temporal_column != "omitted"
+                else {}
+            ),
+        }
+    )
+    existing = {
+        "viz_type": "gauge_chart",
+        "metric": "score",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT: "event_time",
+        "adhoc_filters": [
+            binding,
+            dict(binding),
+            {**binding, "comparator": "Last week"},
+        ],
+    }
+    mapped = map_config_to_form_data(config)
+    merged = merge_chart_form_data(existing, mapped, config)
+    assert (
+        len([f for f in merged["adhoc_filters"] if f["comparator"] == "No filter"]) <= 1
+    )
+    assert [f for f in merged["adhoc_filters"] if f["comparator"] == "Last week"] == [
+        {**binding, "comparator": "Last week"}
+    ]
+
+
+@pytest.mark.parametrize("top_range", ["Last week", "No filter", None])
+def test_gauge_native_no_filter_is_a_dashboard_binding(top_range: str | None) -> None:
+    """The neutral native sentinel must not conflict with a real time restriction."""
+    config = GaugeChartConfig.model_validate(
+        {
+            "metric": "score",
+            "time_range": top_range,
+            "granularity_sqla": "event_time",
+            "adhoc_filters": [
+                {
+                    "subject": "event_time",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": "No filter",
+                },
+                {
+                    "subject": "event_time",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": "Last week",
+                },
+            ],
+        }
+    )
+    assert config.time_range == "Last week"
+    assert config.temporal_column == "event_time"
+    mapped = map_config_to_form_data(config)
+    assert mapped["time_range"] == "Last week"
+    assert mapped["adhoc_filters"][0]["comparator"] == "No filter"
+
+
+def test_gauge_finite_normalization_does_not_mutate_cached_result() -> None:
+    """Filtering dials copies the envelope and updates the returned row count."""
+    from superset.mcp_service.chart.query_result import normalize_gauge_query_result
+
+    rows = [{"score": None}, {"score": 0}, {"score": 42}, {"score": float("inf")}]
+    original = {"queries": [{"data": rows, "rowcount": 4, "colnames": ["score"]}]}
+    normalized = normalize_gauge_query_result(
+        original, {"viz_type": "gauge_chart", "metric": "score"}
+    )
+    assert not isinstance(normalized, ChartError)
+    assert normalized["queries"][0]["data"] == [{"score": 0}, {"score": 42}]
+    assert normalized["queries"][0]["rowcount"] == 2
+    assert normalized["queries"][0]["colnames"] == ["score"]
+    assert original["queries"][0]["data"] is rows
+    assert original["queries"][0]["rowcount"] == 4
+    assert len(rows) == 4
+
+
+@pytest.mark.parametrize("top_range", ["", "   ", "\t", "No filter", " No filter "])
+@pytest.mark.parametrize("comparator", ["Last week", "", "   ", "No filter"])
+def test_gauge_native_time_sentinels_are_neutral(
+    top_range: str, comparator: str
+) -> None:
+    """Explicit blank sentinels are neutral; absent comparators still reject."""
+    config = GaugeChartConfig.model_validate(
+        {
+            "metric": "score",
+            "time_range": top_range,
+            "adhoc_filters": [
+                {
+                    "subject": "event_time",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": comparator,
+                }
+            ],
+        }
+    )
+    if comparator == "Last week":
+        assert config.time_range == "Last week"
+        assert config.granularity_sqla == "event_time"
+    else:
+        assert config.temporal_column == "event_time"
+        assert config.time_range in (None, "No filter")
diff --git a/tests/unit_tests/mcp_service/chart/test_gauge_render.py b/tests/unit_tests/mcp_service/chart/test_gauge_render.py
new file mode 100644
index 00000000000..4abe3ce1ab6
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_gauge_render.py
@@ -0,0 +1,106 @@
+# 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.
+
+"""Optional renderer regression: provide Node's vega@5 and vega-lite@5 via NODE_PATH."""
+
+import os
+import shutil
+import subprocess
+
+import pytest
+
+from superset.mcp_service.chart.preview_utils import generate_gauge_vega_lite_preview
+from superset.mcp_service.chart.schemas import ChartError
+from superset.utils import json
+
+
+@pytest.mark.skipif(
+    not os.environ.get("NODE_PATH"), reason="Requires vega@5/vega-lite@5 in NODE_PATH"
+)
+def test_gauge_rendered_pointer_origin_matches_arc_center() -> None:
+    """Check actual scenegraph geometry in every facet, not only needle length."""
+    specs = []
+    for groupby in ([], ["team"]):
+        for pointer in (True, False):
+            for start, end in ((225, -45), (180, 0), (90, -90)):
+                preview = generate_gauge_vega_lite_preview(
+                    [{"team": "A", "score": 0}, {"team": "B", "score": 75}]
+                    if groupby
+                    else [{"score": 75}],
+                    {
+                        "metric": "score",
+                        "groupby": groupby,
+                        "show_pointer": pointer,
+                        "start_angle": start,
+                        "end_angle": end,
+                        "min_val": 0,
+                        "max_val": 100,
+                        "intervals": "30,70,100",
+                    },
+                )
+                assert not isinstance(preview, ChartError)
+                specs.append(preview.specification)
+    script = r"""
+const assert = require('assert/strict');
+const vl = require('vega-lite');
+const vega = require('vega');
+const fs = require('fs');
+(async () => {
+  for (const spec of JSON.parse(fs.readFileSync(0, 'utf8'))) {
+    const warnings = [];
+    const compiled = vl.compile(spec, {logger: {
+      warn: message => warnings.push(message), info() {}, debug() {},
+      error: message => { throw Error(message); }
+    }}).spec;
+    assert.deepEqual(warnings, []);
+    const view = new vega.View(vega.parse(compiled), {renderer: 'none'});
+    await view.runAsync();
+    assert((await view.toSVG()).includes('<path'));
+    const rules = [];
+    function walk(item, type, target) {
+      if (item.mark?.marktype === type) target.push(item);
+      for (const child of item.items || []) walk(child, type, target);
+    }
+    walk(view.scenegraph().root, 'rule', rules);
+    assert.equal(rules.length, spec.usermeta.show_pointer ? (spec.facet ? 2 : 1) : 0);
+    for (const rule of rules) {
+      const arcs = [];
+      walk(rule.mark.group, 'arc', arcs);
+      assert(arcs.length > 0);
+      for (const arc of arcs) {
+        assert.equal(rule.x, arc.x, 'needle x must be at its own dial center');
+        assert.equal(rule.y, arc.y, 'needle y must be at its own dial center');
+      }
+      const radius = (arcs[0].innerRadius + arcs[0].outerRadius) / 2;
+      assert(radius > arcs[0].innerRadius && radius < arcs[0].outerRadius);
+      assert(rule.x > 0 && rule.y > 0);
+      assert([rule.x, rule.y, rule.x2, rule.y2].every(Number.isFinite));
+      assert(Math.abs(Math.hypot(rule.x2 - rule.x, rule.y2 - rule.y) - radius) < 1e-8);
+      const angle = rule.datum.__mcp_gauge_angle;
+      assert(Math.abs(rule.x2 - rule.x - radius * Math.sin(angle)) < 1e-8);
+      assert(Math.abs(rule.y2 - rule.y + radius * Math.cos(angle)) < 1e-8);
+    }
+    view.finalize();
+  }
+})().catch(error => { console.error(error); process.exit(1); });
+"""
+    node = shutil.which("node")
+    assert node is not None, "The opted-in renderer regression requires Node"
+    # Execute only this fixed test script; generated fixture data enters via stdin.
+    subprocess.run(  # noqa: S603
+        [node, "-e", script], input=json.dumps(specs), text=True, check=True
+    )
diff --git a/tests/unit_tests/mcp_service/chart/test_preview_utils.py b/tests/unit_tests/mcp_service/chart/test_preview_utils.py
index fcac2b97f05..41925b85a8f 100644
--- a/tests/unit_tests/mcp_service/chart/test_preview_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_preview_utils.py
@@ -22,6 +22,7 @@ Tests for preview_utils query context column building.
 import ast
 import inspect
 from pathlib import Path
+from unittest.mock import Mock, patch
 
 from superset.mcp_service.chart import preview_utils
 
@@ -209,3 +210,73 @@ def test_build_query_columns_empty_columns_key_keeps_groupby():
     assert preview_utils._build_query_columns(
         {"groupby": ["country"], "columns": []}
     ) == ["country"]
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+@patch("superset.extensions.db.session.get")
+def test_unsaved_gauge_preview_uses_shared_builder_and_preserves_ordering(
+    mock_find_dataset, mock_build_query_context, mock_command
+):
+    """Unsaved previews execute the same Gauge QueryObject path as Explore."""
+    mock_find_dataset.return_value = Mock(id=7)
+    mock_build_query_context.return_value = Mock()
+    mock_command.return_value.validate.return_value = None
+    mock_command.return_value.run.return_value = {
+        "queries": [{"data": [{"AVG(score)": 75}]}]
+    }
+    form_data = {
+        "viz_type": "gauge_chart",
+        "metric": {
+            "expressionType": "SIMPLE",
+            "aggregate": "AVG",
+            "column": {"column_name": "score"},
+            "label": "AVG(score)",
+        },
+        "groupby": [],
+        "sort_by_metric": True,
+        "row_limit": 4,
+        "intervals": "30,70,200",
+        "datasource_id": 99,
+        "datasource_type": "query",
+        "datasource": "99__query",
+    }
+
+    result = preview_utils.generate_preview_from_form_data(
+        form_data, dataset_id=7, preview_format="ascii"
+    )
+
+    assert result.ascii_content.startswith("Gauge Chart")
+    query_form_data = mock_build_query_context.call_args.args[0]
+    assert query_form_data["sort_by_metric"] is True
+    assert query_form_data["datasource"] == "7__table"
+    assert query_form_data["datasource_id"] == 7
+    assert query_form_data["datasource_type"] == "table"
+    from superset.mcp_service.chart.chart_helpers import resolve_form_data_datasource
+
+    assert resolve_form_data_datasource(query_form_data) == (7, "table")
+    assert form_data["datasource_id"] == 99
+    mock_build_query_context.assert_called_once_with(
+        query_form_data, row_limit=4, force=False
+    )
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+@patch("superset.extensions.db.session.get")
+def test_unsaved_gauge_preview_surfaces_query_error(
+    mock_find_dataset, mock_build_query_context, mock_command
+):
+    mock_find_dataset.return_value = Mock(id=7)
+    mock_build_query_context.return_value = Mock()
+    mock_command.return_value.validate.return_value = None
+    mock_command.return_value.run.return_value = {
+        "queries": [{"status": "failed", "error": "bad metric", "data": []}]
+    }
+    result = preview_utils.generate_preview_from_form_data(
+        {"viz_type": "gauge_chart", "metric": "saved_sla"},
+        dataset_id=7,
+        preview_format="vega_lite",
+    )
+    assert result.error_type == "QueryError"
+    assert "bad metric" in result.error
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
index a740970c641..7398f4b86ea 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
@@ -30,6 +30,7 @@ from superset.mcp_service.chart.schemas import (
     AxisConfig,
     ColumnRef,
     FilterConfig,
+    GaugeChartConfig,
     GenerateChartRequest,
     LegendConfig,
     TableChartConfig,
@@ -98,6 +99,91 @@ class TestGenerateChart:
 
         assert result.chart_type_label == "table chart"
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("has_finite", [True, False])
+    async def test_unsaved_gauge_generate_preserves_controls_and_compiles(
+        self, has_finite: bool
+    ) -> None:
+        """Gauge generate returns native form_data and checks concrete values."""
+        request = GenerateChartRequest(
+            dataset_id=7,
+            config=GaugeChartConfig(
+                chart_type="gauge",
+                metric={"name": "score", "aggregate": "AVG"},
+                groupby=[{"name": "team"}],
+                min_val=0,
+                max_val=100,
+                number_format=",.1f",
+                value_formatter="{value}%",
+                show_pointer=False,
+                intervals="50,100",
+                interval_color_indices="1,3",
+            ),
+            preview_formats=["url"],
+        )
+        ctx = MagicMock(
+            info=AsyncMock(),
+            debug=AsyncMock(),
+            warning=AsyncMock(),
+            error=AsyncMock(),
+            report_progress=AsyncMock(),
+        )
+        validation_result = Mock(
+            is_valid=True, request=request, warnings={}, error=None
+        )
+        dataset = Mock(id=7, datasource_name="scores", table_name="scores")
+        user = Mock(id=1, username="admin", roles=[], groups=[])
+
+        with (
+            patch("superset.mcp_service.auth.get_user_from_request", return_value=user),
+            patch(
+                "superset.mcp_service.chart.validation.ValidationPipeline."
+                "validate_request_with_warnings",
+                return_value=validation_result,
+            ),
+            patch(
+                "superset.mcp_service.chart.chart_utils.generate_explore_link",
+                return_value="http://localhost/explore/?form_data_key=gauge-key",
+            ),
+            patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+            patch(
+                "superset.mcp_service.chart.tool.generate_chart.has_dataset_access",
+                return_value=True,
+            ),
+            patch(
+                "superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data",
+                return_value=Mock(),
+            ) as mock_build,
+            patch(
+                "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            ) as mock_command,
+        ):
+            mock_command.return_value.run.return_value = {
+                "queries": [
+                    {
+                        "data": [
+                            {"team": "Empty", "AVG(score)": None},
+                            {"team": "NaN", "AVG(score)": float("nan")},
+                        ]
+                        + ([{"team": "Blue", "AVG(score)": 75}] if has_finite else [])
+                    }
+                ]
+            }
+            result = await generate_chart(request, ctx=ctx)
+        if not has_finite:
+            assert result.success is False
+            assert result.error is not None
+            return
+
+        assert result.success is True
+        assert result.form_data["viz_type"] == "gauge_chart"
+        assert result.form_data["metric"]["label"] == "AVG(score)"
+        assert result.form_data["groupby"] == ["team"]
+        assert result.form_data["show_pointer"] is False
+        assert result.form_data["intervals"] == "50,100"
+        assert mock_build.call_args.kwargs["row_limit"] == 10
+        mock_command.return_value.validate.assert_called_once()
+
     @pytest.mark.asyncio
     async def test_generate_chart_request_structure(self):
         """Test that chart generation request structures are properly formed."""
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
index 4bb74aaff0d..5570ad0562b 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
@@ -23,9 +23,10 @@ import importlib
 from contextlib import nullcontext
 from types import SimpleNamespace
 from typing import Any
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
 
 import pytest
+from fastmcp import Client
 
 from superset.mcp_service.chart.chart_helpers import (
     rejected_requested_filter_columns,
@@ -513,6 +514,86 @@ class _AsyncContext:
 
 
 class TestUnsavedChartDataQueryConstruction:
+    @pytest.mark.asyncio
+    async def test_gauge_preserves_sort_order_and_validates_saved_metric_output(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Unsaved Gauge get-data uses buildQuery ordering and numeric checks."""
+        chart_data_module = importlib.import_module(
+            "superset.mcp_service.chart.tool.get_chart_data"
+        )
+        query_context_factory_module = importlib.import_module(
+            "superset.common.query_context_factory"
+        )
+        get_data_command_module = importlib.import_module(
+            "superset.commands.chart.data.get_data_command"
+        )
+        captured: list[dict[str, Any]] = []
+
+        class QueryContextFactory:
+            def create(self, **kwargs: Any) -> object:
+                captured.append(kwargs)
+                return object()
+
+        class ChartDataCommand:
+            def __init__(self, query_context: object) -> None:
+                self.query_context = query_context
+
+            def validate(self) -> None:
+                pass
+
+            def run(self) -> dict[str, Any]:
+                return {
+                    "queries": [
+                        {
+                            "data": [
+                                {"team": "Empty", "saved_sla": None},
+                                {"team": "Blue", "saved_sla": 98.5},
+                                {"team": "NaN", "saved_sla": float("nan")},
+                            ],
+                            "colnames": ["team", "saved_sla"],
+                            "rowcount": 3,
+                        }
+                    ]
+                }
+
+        monkeypatch.setattr(
+            query_context_factory_module, "QueryContextFactory", QueryContextFactory
+        )
+        monkeypatch.setattr(
+            get_data_command_module, "ChartDataCommand", ChartDataCommand
+        )
+        monkeypatch.setattr(
+            chart_data_module,
+            "event_logger",
+            SimpleNamespace(log_context=lambda **kwargs: nullcontext()),
+        )
+        monkeypatch.setattr(
+            "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+            lambda datasource_id, datasource_type: "base",
+        )
+        result = await _query_from_form_data(
+            {
+                "datasource": "1__table",
+                "viz_type": "gauge_chart",
+                "metric": "saved_sla",
+                "groupby": ["team"],
+                "sort_by_metric": True,
+                "row_limit": 5,
+            },
+            GetChartDataRequest(form_data_key="cached-key"),
+            _AsyncContext(),
+        )
+
+        assert not isinstance(result, ChartError)
+        assert [row["team"] for row in result.data] == ["Empty", "Blue", "NaN"]
+        assert result.row_count == result.total_rows == 3
+        assert result.data_quality["completeness"] == pytest.approx(5 / 6)
+        query = captured[0]["queries"][0]
+        assert query["metrics"] == ["saved_sla"]
+        assert query["orderby"] == [("saved_sla", False)]
+        assert query["row_limit"] == 5
+
     @pytest.mark.asyncio
     async def test_form_data_key_adhoc_filters_become_query_filters(
         self,
@@ -1673,6 +1754,169 @@ class TestSavedChartExtraFormDataFilters:
         assert "does_not_exist" in data["error"]
         assert "USA" not in result.content[0].text
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("data_path", ["saved", "saved_cache", "unsaved_cache"])
+    @pytest.mark.parametrize("export_format", ["json", "csv", "excel"])
+    @pytest.mark.parametrize("has_finite", [True, False])
+    async def test_gauge_fastmcp_preserves_raw_groups_and_exports(
+        self,
+        mcp_server: Any,
+        mock_auth: Any,
+        data_path: str,
+        export_format: str,
+        has_finite: bool,
+    ) -> None:
+        """Raw Gauge inspection and exports retain every source group, even all-null."""
+        module = importlib.import_module(
+            "superset.mcp_service.chart.tool.get_chart_data"
+        )
+        chart = SimpleNamespace(
+            id=10,
+            slice_name="SLA",
+            viz_type="gauge_chart",
+            datasource_id=1,
+            datasource_type="table",
+            query_context=json.dumps(
+                {
+                    "datasource": {"id": 1, "type": "table"},
+                    "queries": [
+                        {
+                            "columns": ["team"],
+                            "metrics": ["saved_sla"],
+                            "row_limit": 10,
+                        }
+                    ],
+                }
+            ),
+            params=json.dumps(
+                {
+                    "viz_type": "gauge_chart",
+                    "metric": "saved_sla",
+                    "groupby": ["team"],
+                }
+            ),
+        )
+
+        def fake_load(self: Any, data: dict[str, Any]) -> Any:
+            return SimpleNamespace(
+                queries=[
+                    SimpleNamespace(
+                        filter=[],
+                        time_range=None,
+                        to_dict=lambda: dict(data["queries"][0]),
+                    )
+                ],
+                form_data={},
+            )
+
+        rows: list[dict[str, Any]] = [
+            {"team": "Empty", "saved_sla": None},
+            {"team": "NaN", "saved_sla": float("nan")},
+            {"team": "Infinity", "saved_sla": float("inf")},
+            {"team": "Negative infinity", "saved_sla": -float("inf")},
+        ]
+        if has_finite:
+            rows.append({"team": "Blue", "saved_sla": 42})
+        source_rowcount = len(rows) + 7
+        payload = {
+            "queries": [
+                {
+                    "data": rows,
+                    "rowcount": source_rowcount,
+                    "colnames": ["team", "saved_sla"],
+                }
+            ]
+        }
+
+        class Command:
+            def __init__(self, query_context: Any) -> None: ...
+            def validate(self) -> None: ...
+            def run(self) -> dict[str, Any]:
+                return payload
+
+        cached_form_data = {
+            "viz_type": "gauge_chart",
+            "datasource": "1__table",
+            "metric": "saved_sla",
+            "groupby": ["team"],
+            "slice_name": "SLA",
+        }
+        query = {"columns": ["team"], "metrics": ["saved_sla"]}
+        with (
+            patch.object(
+                module,
+                "get_cached_form_data",
+                return_value=json.dumps(cached_form_data),
+            ),
+            patch.object(
+                module, "build_query_dicts_from_form_data", return_value=[query]
+            ),
+            patch.object(
+                module,
+                "build_query_context_from_form_data",
+                return_value=fake_load(None, {"queries": [query]}),
+            ),
+            patch.object(module, "find_chart_by_identifier", return_value=chart),
+            patch.object(
+                module,
+                "validate_chart_dataset",
+                return_value=SimpleNamespace(is_valid=True, warnings=[], error=None),
+            ),
+            patch(
+                "superset.charts.schemas.ChartDataQueryContextSchema.load", fake_load
+            ),
+            patch(
+                "superset.commands.chart.data.get_data_command.ChartDataCommand",
+                Command,
+            ),
+        ):
+            async with Client(mcp_server) as client:
+                request = {"format": export_format}
+                if data_path != "unsaved_cache":
+                    request["identifier"] = "10"
+                if data_path != "saved":
+                    request["form_data_key"] = "raw-gauge-cache"
+                result = await client.call_tool("get_chart_data", {"request": request})
+
+        data = json.loads(result.content[0].text)
+        assert "error_type" not in data, data
+        assert data["row_count"] == len(rows)
+        assert data["total_rows"] == (
+            source_rowcount if export_format == "json" else len(rows)
+        )
+        expected_groups = [row["team"] for row in rows]
+        if export_format == "json":
+            assert [row["team"] for row in data["data"]] == expected_groups
+            assert data["data_quality"]["completeness"] == pytest.approx(
+                1 - 1 / (len(rows) * 2)
+            )
+            assert data.get("query_results") is None
+        elif export_format == "csv":
+            import csv
+            from io import StringIO
+
+            exported = list(csv.DictReader(StringIO(data["csv_data"])))
+            assert [row["team"] for row in exported] == expected_groups
+        else:
+            import base64
+            from io import BytesIO
+
+            from openpyxl import load_workbook
+
+            workbook = load_workbook(BytesIO(base64.b64decode(data["excel_data"])))
+            assert list(workbook.active.values)[0] == ("team", "saved_sla")
+            assert [
+                row[0] for row in list(workbook.active.values)[1:]
+            ] == expected_groups
+            assert [row[1] for row in list(workbook.active.values)[1:]] == [
+                None,
+                "nan",
+                "inf",
+                "-inf",
+            ] + ([42] if has_finite else [])
+        assert payload["queries"][0]["data"] is rows
+        assert payload["queries"][0]["rowcount"] == source_rowcount
+
 
 class TestOAuthErrorRouting:
     """Query-time OAuth errors must reach the dedicated OAuth handlers.
@@ -1942,6 +2186,14 @@ def test_recommend_single_numeric_suggests_kpi():
     cols = [_col("total_revenue", "numeric")]
     result = _recommend_visualizations("table", cols, row_count=1)
     assert "big number / KPI" in result
+    assert "gauge chart" in result
+
+
+def test_recommend_single_numeric_excludes_current_gauge():
+    cols = [_col("total_revenue", "numeric")]
+    result = _recommend_visualizations("gauge_chart", cols, row_count=1)
+    assert "gauge chart" not in result
+    assert "big number / KPI" in result
 
 
 def test_recommend_all_strings_falls_back():
@@ -2618,3 +2870,30 @@ async def test_query_from_form_data_refreshed_reflects_force_refresh_only(
     assert isinstance(result, ChartData)
     assert result.cache_status is not None
     assert result.cache_status.refreshed is expected_refreshed
+
+
+def test_xlsxwriter_preserves_nonfinite_group_rows() -> None:
+    """The optional XLSX fallback preserves groups with non-finite metrics."""
+    import base64
+    from io import BytesIO
+
+    from openpyxl import load_workbook
+
+    from superset.mcp_service.chart.tool.get_chart_data import (
+        _create_excel_with_xlsxwriter,
+    )
+
+    rows: list[dict[str, Any]] = [
+        {"team": "Empty", "score": None},
+        {"team": "NaN", "score": float("nan")},
+        {"team": "Infinity", "score": float("inf")},
+        {"team": "Negative infinity", "score": -float("inf")},
+        {"team": "Finite", "score": 42},
+    ]
+    chart = MagicMock(slice_name="Gauge")
+    content = _create_excel_with_xlsxwriter(chart, rows, ["team", "score"])
+    workbook = load_workbook(BytesIO(base64.b64decode(content)))
+    assert list(workbook.active.values)[0] == ("team", "score")
+    assert [row[0] for row in list(workbook.active.values)[1:]] == [
+        row["team"] for row in rows
+    ]
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py
index a3127904fc3..6534e6f2bfc 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py
@@ -47,10 +47,85 @@ from superset.mcp_service.chart.tool.get_chart_preview import (
     ASCIIPreviewStrategy,
     PreviewFormatStrategy,
     TablePreviewStrategy,
+    VegaLitePreviewStrategy,
 )
 from superset.utils import json as utils_json
 
 
+def _gauge_chart() -> SimpleNamespace:
+    """Build a saved Gauge chart with its native form_data controls."""
+    return SimpleNamespace(
+        id=104,
+        slice_name="SLA Gauge",
+        viz_type="gauge_chart",
+        datasource_id=1,
+        datasource_type="table",
+        params=utils_json.dumps(
+            {
+                "viz_type": "gauge_chart",
+                "metric": "saved_sla",
+                "groupby": ["team"],
+                "row_limit": 3,
+                "min_val": 0,
+                "max_val": 100,
+                "value_formatter": "{value}%",
+            }
+        ),
+    )
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch(
+    "superset.mcp_service.chart.tool.get_chart_preview."
+    "build_query_context_from_form_data"
+)
+def test_saved_gauge_ascii_preview_uses_native_row_limit_and_renderer(
+    mock_build_query_context, mock_command
+) -> None:
+    query_context = SimpleNamespace(
+        queries=[SimpleNamespace(metrics=["saved_sla"], columns=["team"])]
+    )
+    mock_build_query_context.return_value = query_context
+    mock_command.return_value.validate.return_value = None
+    mock_command.return_value.run.return_value = {
+        "queries": [{"data": [{"team": "Blue", "saved_sla": 88}]}]
+    }
+
+    preview = ASCIIPreviewStrategy(
+        _gauge_chart(), GetChartPreviewRequest(identifier=104, format="ascii")
+    ).generate()
+
+    assert isinstance(preview, ASCIIPreview)
+    assert "Gauge Chart" in preview.ascii_content
+    assert "Blue" in preview.ascii_content
+    assert "88%" in preview.ascii_content
+    assert mock_build_query_context.call_args.kwargs["row_limit"] == 3
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch(
+    "superset.mcp_service.chart.tool.get_chart_preview."
+    "build_query_context_from_form_data"
+)
+def test_saved_gauge_vega_preview_surfaces_runtime_metric_error(
+    mock_build_query_context, mock_command
+) -> None:
+    mock_build_query_context.return_value = SimpleNamespace(
+        queries=[SimpleNamespace(metrics=["saved_sla"], columns=["team"])]
+    )
+    mock_command.return_value.validate.return_value = None
+    mock_command.return_value.run.return_value = {
+        "queries": [{"data": [{"team": "Blue", "saved_sla": "bad"}]}]
+    }
+
+    preview = VegaLitePreviewStrategy(
+        _gauge_chart(), GetChartPreviewRequest(identifier=104, format="vega_lite")
+    ).generate()
+
+    assert isinstance(preview, ChartError)
+    assert preview.error_type == "NonNumericGaugeMetric"
+
+
 class TestPreviewXAxisInQueryContext:
     """Tests for x_axis inclusion in preview query context columns.
 
@@ -1439,3 +1514,80 @@ def test_authorize_guest_query_noop_for_non_guest() -> None:
         strategy._authorize_guest_query(MagicMock())
 
     mock_authorize.assert_not_called()
+
+
+@pytest.mark.parametrize("stored_viz_type", [None, "table", "gauge_chart"])
+@pytest.mark.parametrize(
+    "strategy", [ASCIIPreviewStrategy, TablePreviewStrategy, VegaLitePreviewStrategy]
+)
+def test_saved_gauge_dispatch_and_validation_agree(
+    stored_viz_type: str | None, strategy: type[PreviewFormatStrategy]
+) -> None:
+    """Saved chart identity drives query construction and numeric validation."""
+    chart = _gauge_chart()
+    form_data = utils_json.loads(chart.params)
+    form_data["viz_type"] = stored_viz_type
+    chart.params = utils_json.dumps(form_data)
+    with (
+        patch(
+            "superset.mcp_service.chart.tool.get_chart_preview.build_query_context_from_form_data"
+        ) as build,
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+    ):
+        build.return_value = SimpleNamespace(
+            queries=[SimpleNamespace(metrics=["saved_sla"])]
+        )
+        command.return_value.run.return_value = {
+            "queries": [{"data": [{"team": "Blue", "saved_sla": "bad"}]}]
+        }
+        result = strategy(chart, GetChartPreviewRequest(identifier=104)).generate()
+    assert isinstance(result, ChartError)
+    assert result.error_type == "NonNumericGaugeMetric"
+    assert build.call_args.args[0]["viz_type"] == "gauge_chart"
+
+
+@pytest.mark.parametrize(
+    "strategy", [ASCIIPreviewStrategy, TablePreviewStrategy, VegaLitePreviewStrategy]
+)
+def test_saved_gauge_preview_skips_empty_aggregate_groups(
+    strategy: type[PreviewFormatStrategy],
+) -> None:
+    """Every saved preview format retains the finite dial from mixed query output."""
+    chart = _gauge_chart()
+    with (
+        patch(
+            "superset.mcp_service.chart.tool.get_chart_preview.build_query_context_from_form_data"
+        ) as build,
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+    ):
+        build.return_value = SimpleNamespace(
+            queries=[SimpleNamespace(metrics=["saved_sla"])]
+        )
+        command.return_value.run.return_value = {
+            "queries": [
+                {
+                    "data": [
+                        {"team": "Empty", "saved_sla": None},
+                        {"team": "NaN", "saved_sla": float("nan")},
+                        {"team": "Blue", "saved_sla": 42},
+                    ]
+                }
+            ]
+        }
+        result = strategy(chart, GetChartPreviewRequest(identifier=104)).generate()
+    assert not isinstance(result, ChartError)
+    if isinstance(result, VegaLitePreview):
+        assert [row["saved_sla"] for row in result.specification["data"]["values"]] == [
+            42
+        ]
+    elif isinstance(result, TablePreview):
+        assert result.row_count == 1
+        assert "Blue" in result.table_data
+        assert "Empty" not in result.table_data
+    else:
+        assert "Blue" in result.ascii_content
+        assert "Empty" not in result.ascii_content
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
index 687bbc6f606..8a2ab50f6bf 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
@@ -74,6 +74,35 @@ class TestGetChartTypeSchema:
         props = result["schema"]["properties"]
         assert "metric" in props
 
+    def test_gauge_uses_public_identity_and_full_control_schema(self) -> None:
+        result = _call_schema("gauge")
+        assert result["chart_type"] == "gauge"
+        props = result["schema"]["properties"]
+        assert {
+            "metric",
+            "groupby",
+            "sort_by_metric",
+            "min_val",
+            "max_val",
+            "color_scheme",
+            "number_format",
+            "currency_format",
+            "value_formatter",
+            "start_angle",
+            "end_angle",
+            "show_pointer",
+            "show_progress",
+            "intervals",
+            "interval_color_indices",
+            "time_range",
+            "granularity_sqla",
+        } <= set(props)
+        assert all(example["chart_type"] == "gauge" for example in result["examples"])
+
+    def test_native_gauge_alias_returns_public_schema_identity(self) -> None:
+        result = _call_schema("gauge_chart")
+        assert result["chart_type"] == "gauge"
+
     def test_include_examples_false_omits_examples(self) -> None:
         result = _call_schema("xy", include_examples=False)
         assert "schema" in result
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
index 2e654fd2de1..254066711f7 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
@@ -34,6 +34,7 @@ from superset.mcp_service.chart.schemas import (
     AxisConfig,
     ColumnRef,
     FilterConfig,
+    GaugeChartConfig,
     GenerateChartResponse,
     LegendConfig,
     TableChartConfig,
@@ -1256,8 +1257,8 @@ class TestBuildPreviewFormData:
         result = _build_preview_form_data(request, chart, parsed_config=config)
 
         assert isinstance(result, dict)
-        # Existing keys not touched by the new config are preserved
-        assert result["custom_flag"] is True
+        # Cross-viz updates do not inherit stale controls from the prior plugin.
+        assert "custom_flag" not in result
         # New config overrides existing keys
         assert result["viz_type"] == "table"
         # slice_id and datasource are always stamped onto the preview
@@ -1265,6 +1266,121 @@ class TestBuildPreviewFormData:
         assert result["datasource"] == "7__table"
         assert result["slice_name"] == "Existing"
 
+    def test_gauge_saved_and_preview_updates_use_identical_preserving_merge(self):
+        existing = {
+            "viz_type": "gauge_chart",
+            "metric": "saved_sla",
+            "groupby": ["region"],
+            "min_val": 0,
+            "max_val": 100,
+            "font_size": 19,
+            "number_format": ",.1f",
+            "show_pointer": False,
+            "_mcp_dashboard_time_filter_subject": "event_time",
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "event_time",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": "No filter",
+                },
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "event_time",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": "Last week",
+                },
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "country",
+                    "operator": "==",
+                    "comparator": "US",
+                },
+            ],
+        }
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "score", "aggregate": "AVG"},
+            max_val=120,
+            temporal_column=None,
+        )
+        request = UpdateChartRequest(identifier=1, config=config)
+        chart = Mock(
+            id=42,
+            datasource_id=7,
+            slice_name="Existing",
+            params=json.dumps(existing),
+        )
+
+        preview = _build_preview_form_data(request, chart, parsed_config=config)
+        payload = _build_update_payload(request, chart, parsed_config=config)
+
+        assert isinstance(preview, dict)
+        assert isinstance(payload, dict)
+        saved = json.loads(payload["params"])
+        for key in (
+            "metric",
+            "groupby",
+            "min_val",
+            "max_val",
+            "font_size",
+            "number_format",
+            "show_pointer",
+            "adhoc_filters",
+        ):
+            assert preview[key] == saved[key]
+        assert saved["groupby"] == ["region"]
+        assert saved["max_val"] == 120
+
+        assert [
+            f["comparator"]
+            for f in saved["adhoc_filters"]
+            if f["operator"] == "TEMPORAL_RANGE"
+        ] == ["Last week"]
+
+    @pytest.mark.parametrize("temporal_column", [None, "new_time"])
+    def test_gauge_dataset_rebind_scrubs_old_roles_in_both_paths(
+        self, temporal_column: str | None
+    ) -> None:
+        config = GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "new_score", "aggregate": "AVG"},
+            temporal_column=temporal_column,
+        )
+        request = UpdateChartRequest(identifier=1, dataset_id=8, config=config)
+        chart = Mock(
+            id=42,
+            datasource_id=7,
+            slice_name="Existing",
+            params=json.dumps(
+                {
+                    "viz_type": "gauge_chart",
+                    "metric": "old_score",
+                    "groupby": ["old_group"],
+                    "font_size": 18,
+                    "adhoc_filters": [{"subject": "old_filter"}],
+                }
+            ),
+        )
+
+        preview = _build_preview_form_data(request, chart, parsed_config=config)
+        payload = _build_update_payload(request, chart, parsed_config=config)
+
+        assert isinstance(preview, dict)
+        assert isinstance(payload, dict)
+        saved = json.loads(payload["params"])
+        assert preview["datasource"] == saved["datasource"] == "8__table"
+        assert "groupby" not in preview
+        assert "groupby" not in saved
+        assert preview.get("adhoc_filters", []) == saved.get("adhoc_filters", [])
+        assert [f["subject"] for f in preview.get("adhoc_filters", [])] == (
+            [temporal_column] if temporal_column else []
+        )
+        assert preview["font_size"] == saved["font_size"] == 18
+
     def test_partial_column_config_merges_saved_ui_settings(self) -> None:
         config = TableChartConfig.model_validate(
             {
@@ -2201,6 +2317,10 @@ class TestUpdateChartDatasetIdIntegration:
     @patch("superset.daos.chart.ChartDAO.find_by_id", new_callable=Mock)
     @patch("superset.db.session")
     @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "viz_type,target_id",
+        [("table", 1041), ("gauge_chart", 10), ("gauge_chart", 1041)],
+    )
     async def test_dataset_id_passed_to_update_command(
         self,
         mock_db_session: Any,
@@ -2209,13 +2329,15 @@ class TestUpdateChartDatasetIdIntegration:
         mock_update_cmd_cls: Mock,
         mock_check_access: Mock,
         mcp_server: Any,
+        viz_type: str,
+        target_id: int,
     ) -> None:
         """dataset_id in request is forwarded to UpdateChartCommand payload."""
         mock_chart = Mock()
         mock_chart.id = 55
         mock_chart.datasource_id = 10
         mock_chart.slice_name = "Old Chart"
-        mock_chart.viz_type = "table"
+        mock_chart.viz_type = viz_type
         mock_chart.uuid = "uuid-55"
         mock_find_by_id.return_value = mock_chart
 
@@ -2229,24 +2351,32 @@ class TestUpdateChartDatasetIdIntegration:
         updated_chart = Mock()
         updated_chart.id = 55
         updated_chart.slice_name = "Old Chart"
-        updated_chart.viz_type = "table"
+        updated_chart.viz_type = viz_type
         updated_chart.uuid = "uuid-55"
         mock_update_cmd_cls.return_value.run.return_value = updated_chart
 
         request = {
             "identifier": 55,
-            "dataset_id": 1041,
+            "dataset_id": target_id,
             "generate_preview": False,
         }
 
         async with Client(mcp) as client:
             result = await client.call_tool("update_chart", {"request": request})
 
+            if viz_type == "gauge_chart" and target_id != 10:
+                assert result.structured_content["success"] is False
+                assert (
+                    "complete Gauge config"
+                    in result.structured_content["error"]["message"]
+                )
+                mock_update_cmd_cls.assert_not_called()
+                return
             assert result.structured_content["success"] is True
 
             call_args = mock_update_cmd_cls.call_args
             payload = call_args[0][1]
-            assert payload.get("datasource_id") == 1041
+            assert payload.get("datasource_id") == target_id
             assert payload.get("datasource_type") == "table"
 
     @patch(
@@ -2349,3 +2479,68 @@ class TestUpdateChartDatasetIdIntegration:
             error_type = result.structured_content["error"]["error_type"]
             assert error_type == "DatasetNotAccessible"
             assert "9999" in result.structured_content["error"]["details"]
+
+
+@pytest.mark.parametrize("has_finite", [True, False])
+@pytest.mark.parametrize("preview_path", [True, False])
+def test_gauge_update_compile_keeps_finite_groups(
+    has_finite: bool,
+    preview_path: bool,
+) -> None:
+    """Both update payload paths execute the real finite-dial compile contract."""
+    config = GaugeChartConfig(metric={"name": "saved_sla", "saved_metric": True})
+    request = UpdateChartRequest(identifier=1, config=config)
+    dataset = Mock(
+        id=7,
+        table_name="scores",
+        schema=None,
+        columns=[],
+        metrics=[],
+        database=Mock(database_name="examples"),
+    )
+    chart = Mock(
+        id=1,
+        datasource_id=7,
+        datasource=dataset,
+        slice_name="Gauge",
+        params=json.dumps({"viz_type": "gauge_chart", "metric": "saved_sla"}),
+    )
+    if preview_path:
+        form_data = _build_preview_form_data(request, chart, parsed_config=config)
+    else:
+        payload = _build_update_payload(request, chart, parsed_config=config)
+        assert isinstance(payload, dict)
+        form_data = json.loads(payload["params"])
+    assert isinstance(form_data, dict)
+    with (
+        patch(
+            "superset.mcp_service.chart.compile.DatasetValidator.validate_against_dataset",
+            return_value=(True, None),
+        ),
+        patch(
+            "superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data",
+            return_value=Mock(),
+        ) as build,
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+    ):
+        command.return_value.run.return_value = {
+            "queries": [
+                {
+                    "data": [{"saved_sla": None}, {"saved_sla": float("nan")}]
+                    + ([{"saved_sla": 0}] if has_finite else [])
+                }
+            ]
+        }
+        result = update_chart_module._validate_update_against_dataset(
+            config, form_data, chart
+        )
+    if has_finite:
+        assert result is None
+    else:
+        assert result is not None
+        assert result.success is False
+        assert result.error.error_type == "NonNumericGaugeMetric"
+    assert build.call_args.kwargs["row_limit"] == 10
+    command.return_value.validate.assert_called_once()
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
index 2c9fcbda2f0..cc04860eb82 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
@@ -20,7 +20,7 @@ Unit tests for update_chart_preview MCP tool
 """
 
 import importlib
-from typing import Any
+from contextlib import nullcontext
 from unittest.mock import Mock, patch
 
 import pytest
@@ -28,12 +28,11 @@ from fastmcp import Client
 
 from superset.extensions import feature_flag_manager
 from superset.mcp_service.app import mcp
-from superset.mcp_service.chart.chart_utils import map_big_number_config
 from superset.mcp_service.chart.schemas import (
     AxisConfig,
-    BigNumberChartConfig,
     ColumnRef,
     FilterConfig,
+    GaugeChartConfig,
     InteractivePivotChartConfig,
     LegendConfig,
     TableChartConfig,
@@ -84,6 +83,99 @@ def _mock_dataset(id: int = 1) -> Mock:
     return dataset
 
 
+@patch.object(
+    update_chart_preview_module,
+    "event_logger",
+    new=Mock(log_context=lambda **kwargs: nullcontext()),
+)
+@patch.object(update_chart_preview_module, "validate_and_compile")
+@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
+@patch("superset.daos.dataset.DatasetDAO.find_by_id")
+@patch.object(update_chart_preview_module, "analyze_chart_semantics")
+@patch.object(update_chart_preview_module, "analyze_chart_capabilities")
+@patch.object(update_chart_preview_module, "generate_explore_link")
+@patch.object(update_chart_preview_module, "_get_previous_form_data")
+@patch.object(update_chart_preview_module, "_find_dataset")
+def test_cached_gauge_update_preserves_controls_and_compiles(
+    mock_find_dataset,
+    mock_get_previous_form_data,
+    mock_generate_explore_link,
+    mock_capabilities,
+    mock_semantics,
+    mock_find_by_id,
+    unused_access_mock,
+    mock_validate_and_compile,
+    mock_auth,
+) -> None:
+    """Cached Gauge iteration preserves omissions and validates runtime output."""
+    mock_find_dataset.return_value = _mock_dataset(id=3)
+    mock_find_by_id.return_value = _mock_dataset(id=3)
+    mock_get_previous_form_data.return_value = {
+        "viz_type": "gauge_chart",
+        "datasource": "3__table",
+        "metric": "old_sla",
+        "groupby": ["team"],
+        "font_size": 19,
+        "number_format": ",.1f",
+        "show_pointer": False,
+        "_mcp_dashboard_time_filter_subject": "event_time",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "event_time",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "No filter",
+            },
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "event_time",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "Last week",
+            },
+        ],
+    }
+    cached_filters = mock_get_previous_form_data.return_value["adhoc_filters"]
+    cached_filters.insert(0, dict(cached_filters[0]))
+    mock_generate_explore_link.return_value = (
+        "http://localhost:8088/explore/?form_data_key=new_key"
+    )
+    mock_capabilities.return_value = None
+    mock_semantics.return_value = None
+    mock_validate_and_compile.return_value = Mock(success=True, warnings=[])
+    request = UpdateChartPreviewRequest(
+        form_data_key="old_key",
+        dataset_id=3,
+        config=GaugeChartConfig(
+            chart_type="gauge",
+            metric={"name": "new_sla", "saved_metric": True},
+            max_val=120,
+            temporal_column=None,
+        ),
+    )
+
+    result = update_chart_preview_module.update_chart_preview(
+        request=request, ctx=Mock()
+    )
+
+    assert result["success"] is True, result
+    generated = mock_generate_explore_link.call_args.args[1]
+    assert generated["metric"] == "new_sla"
+    assert generated["groupby"] == ["team"]
+    assert generated["font_size"] == 19
+    assert generated["show_pointer"] is False
+    assert generated["max_val"] == 120
+    assert result["form_data"] == generated
+    assert mock_validate_and_compile.call_args.kwargs["run_compile_check"] is True
+
+    assert [
+        f["comparator"]
+        for f in generated["adhoc_filters"]
+        if f["operator"] == "TEMPORAL_RANGE"
+    ] == ["Last week"]
+
+
 class TestUpdateChartPreview:
     """Tests for update_chart_preview MCP tool."""
 
@@ -591,268 +683,6 @@ class TestUpdateChartPreview:
 
         assert result is None
 
-    def test_preserves_generated_temporal_filter_with_cached_filters(self) -> None:
-        """Cached filters are merged without replacing the temporal binding."""
-        new_form_data = {
-            "adhoc_filters": [
-                {
-                    "clause": "WHERE",
-                    "comparator": "No filter",
-                    "expressionType": "SIMPLE",
-                    "operator": "TEMPORAL_RANGE",
-                    "subject": "ds",
-                }
-            ]
-        }
-        previous_form_data = {
-            "adhoc_filters": [
-                {
-                    "clause": "WHERE",
-                    "comparator": "North",
-                    "expressionType": "SIMPLE",
-                    "operator": "==",
-                    "subject": "region",
-                }
-            ]
-        }
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            previous_form_data,
-        )
-
-        assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
-            "region",
-            "ds",
-        ]
-
-    def test_cached_temporal_filter_takes_precedence_over_generated_default(
-        self,
-    ) -> None:
-        """A cached chart-specific time range is not duplicated or reset."""
-        new_form_data = {
-            "adhoc_filters": [
-                {
-                    "clause": "WHERE",
-                    "comparator": "No filter",
-                    "expressionType": "SIMPLE",
-                    "operator": "TEMPORAL_RANGE",
-                    "subject": "ds",
-                }
-            ]
-        }
-        cached_temporal_filter = {
-            "clause": "WHERE",
-            "comparator": "Last month",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "ds",
-        }
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            {"adhoc_filters": [cached_temporal_filter]},
-        )
-
-        assert new_form_data["adhoc_filters"] == [cached_temporal_filter]
-
-    def test_replaces_cached_temporal_filter_when_column_changes(self) -> None:
-        """A newly selected temporal column replaces the cached binding."""
-        new_temporal_filter = {
-            "clause": "WHERE",
-            "comparator": "No filter",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "created_at",
-        }
-        region_filter = {
-            "clause": "WHERE",
-            "comparator": "North",
-            "expressionType": "SIMPLE",
-            "operator": "==",
-            "subject": "region",
-        }
-        previous_temporal_filter = {
-            "clause": "WHERE",
-            "comparator": "No filter",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "ds",
-        }
-        new_form_data: dict[str, Any] = {"adhoc_filters": [new_temporal_filter]}
-        new_form_data["_mcp_dashboard_time_filter_subject"] = "created_at"
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            {
-                "adhoc_filters": [region_filter, previous_temporal_filter],
-                "_mcp_dashboard_time_filter_subject": "ds",
-            },
-        )
-
-        assert new_form_data["adhoc_filters"] == [
-            region_filter,
-            new_temporal_filter,
-        ]
-
-    def test_replaces_temporal_xy_binding_when_subject_changes(self) -> None:
-        """A temporal XY binding does not survive rebinding to a new subject."""
-        previous_binding = {
-            "clause": "WHERE",
-            "comparator": "No filter",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "event_time",
-        }
-        new_binding = {
-            **previous_binding,
-            "subject": "created_at",
-        }
-        new_form_data = {
-            "adhoc_filters": [new_binding],
-            "_mcp_dashboard_time_filter_subject": "created_at",
-        }
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            {
-                "adhoc_filters": [previous_binding],
-                "_mcp_dashboard_time_filter_subject": "event_time",
-            },
-        )
-
-        assert new_form_data["adhoc_filters"] == [new_binding]
-
-    def test_replaces_big_number_fallback_binding_when_subject_changes(self) -> None:
-        """A Big Number fallback binding is replaced by a selected subject."""
-        dataset = Mock(
-            main_dttm_col=None,
-            columns=[Mock(column_name="order_date")],
-        )
-        config = BigNumberChartConfig(
-            chart_type="big_number",
-            metric=ColumnRef(name="revenue", aggregate="SUM"),
-        )
-        rebound_config = config.model_copy(update={"temporal_column": "created_at"})
-
-        with (
-            patch(
-                "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid",
-                return_value=dataset,
-            ),
-            patch(
-                "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
-                return_value=True,
-            ),
-        ):
-            previous_form_data = map_big_number_config(config, dataset_id=42)
-            new_form_data = map_big_number_config(rebound_config, dataset_id=42)
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            previous_form_data,
-        )
-
-        assert previous_form_data["_mcp_dashboard_time_filter_subject"] == "order_date"
-        assert new_form_data["_mcp_dashboard_time_filter_subject"] == "created_at"
-        assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
-            "created_at"
-        ]
-
-    def test_removes_cached_temporal_filter_without_new_binding(self) -> None:
-        """A mapping without a temporal subject drops the cached binding."""
-        region_filter = {
-            "clause": "WHERE",
-            "comparator": "North",
-            "expressionType": "SIMPLE",
-            "operator": "==",
-            "subject": "region",
-        }
-        previous_temporal_filter = {
-            "clause": "WHERE",
-            "comparator": "No filter",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "ds",
-        }
-        new_form_data: dict[str, Any] = {}
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            {
-                "adhoc_filters": [region_filter, previous_temporal_filter],
-                "_mcp_dashboard_time_filter_subject": "ds",
-            },
-        )
-
-        assert new_form_data["adhoc_filters"] == [region_filter]
-
-    def test_preserves_user_temporal_filter_on_generated_subject(self) -> None:
-        """A user-authored range on the binding subject is not generated state."""
-        generated_binding = {
-            "clause": "WHERE",
-            "comparator": "No filter",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "ds",
-        }
-        user_filter = {
-            "clause": "WHERE",
-            "comparator": "Last month",
-            "expressionType": "SIMPLE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "ds",
-        }
-
-        new_form_data: dict[str, Any] = {}
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            {
-                "adhoc_filters": [generated_binding, user_filter],
-                "_mcp_dashboard_time_filter_subject": "ds",
-            },
-        )
-
-        assert new_form_data["adhoc_filters"] == [user_filter]
-
-    def test_rebinding_preserves_unrelated_cached_temporal_filter(self) -> None:
-        """Only the generated binding is replaced; user filters retain provenance."""
-        previous_binding = {
-            "expressionType": "SIMPLE",
-            "clause": "WHERE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "ds",
-            "comparator": "No filter",
-        }
-        unrelated_filter = {
-            "expressionType": "SIMPLE",
-            "clause": "WHERE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "processed_at",
-            "comparator": "Last year",
-        }
-        new_binding = {
-            "expressionType": "SIMPLE",
-            "clause": "WHERE",
-            "operator": "TEMPORAL_RANGE",
-            "subject": "created_at",
-            "comparator": "No filter",
-        }
-        new_form_data = {
-            "adhoc_filters": [new_binding],
-            "_mcp_dashboard_time_filter_subject": "created_at",
-        }
-
-        update_chart_preview_module._preserve_previous_adhoc_filters(
-            new_form_data,
-            {
-                "adhoc_filters": [previous_binding, unrelated_filter],
-                "_mcp_dashboard_time_filter_subject": "ds",
-            },
-        )
-
-        assert new_form_data["adhoc_filters"] == [unrelated_filter, new_binding]
-
     @patch.object(update_chart_preview_module, "validate_and_compile")
     @patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
     @patch("superset.daos.dataset.DatasetDAO.find_by_id")
diff --git a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
index b25f90fbcef..dba58131399 100644
--- a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
+++ b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
@@ -21,10 +21,11 @@ Comprehensive unit tests for MCP generate_explore_link tool
 
 import importlib
 import logging
+from typing import Any
 from unittest.mock import Mock, patch
 
 import pytest
-from fastmcp import Client
+from fastmcp import Client, FastMCP
 
 from superset.mcp_service.app import mcp
 from superset.mcp_service.chart.schemas import (
@@ -46,6 +47,56 @@ generate_explore_link_module = importlib.import_module(
     "superset.mcp_service.explore.tool.generate_explore_link"
 )
 
+
+@pytest.mark.parametrize("time_range", ["", "   ", "No filter"])
+@pytest.mark.parametrize("comparator", ["Last week", "", "   "])
+@patch.object(generate_explore_link_module, "validate_and_compile")
+@patch("superset.daos.dataset.DatasetDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_gauge_fastmcp_entry_compiles_and_returns_native_form_data(
+    mock_find_dataset, mock_validate, mcp_server, time_range: str, comparator: str
+) -> None:
+    """The public Gauge request reaches compile and a native Explore payload."""
+    from superset.mcp_service.chart.compile import CompileResult
+
+    mock_find_dataset.return_value = _mock_dataset(id=3)
+    mock_validate.return_value = CompileResult(success=True)
+    request = {
+        "dataset_id": "3",
+        "config": {
+            "chart_type": "gauge",
+            "metric": {"name": "num", "aggregate": "AVG"},
+            "min_val": 0,
+            "max_val": 100,
+            "number_format": ",.1f",
+            "time_range": time_range,
+            "adhoc_filters": [
+                {
+                    "subject": "event_time",
+                    "operator": "TEMPORAL_RANGE",
+                    "comparator": comparator,
+                }
+            ],
+        },
+    }
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("generate_explore_link", {"request": request})
+
+    assert result.structured_content["success"] is True
+    form_data = result.structured_content["form_data"]
+    assert form_data["viz_type"] == "gauge_chart"
+    assert form_data["metric"]["label"] == "AVG(num)"
+    assert form_data["number_format"] == ",.1f"
+    if comparator == "Last week":
+        assert form_data["time_range"] == "Last week"
+    else:
+        assert form_data.get("time_range") in (None, "No filter")
+        assert form_data["adhoc_filters"][0]["subject"] == "event_time"
+        assert form_data["adhoc_filters"][0]["comparator"] == "No filter"
+    assert mock_validate.call_args.kwargs["run_compile_check"] is True
+
+
 logging.basicConfig(level=logging.DEBUG)
 logger = logging.getLogger(__name__)
 
@@ -1227,3 +1278,73 @@ class TestGenerateExploreLinkValidation:
             assert error["error_type"] == "permission_denied"
             assert "Dataset not found" in error["message"]
             mock_create_permalink.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "filter_value",
+    [
+        {"subject": "event_time", "operator": "TEMPORAL_RANGE"},
+        {"subject": "event_time", "operator": "TEMPORAL_RANGE", "comparator": None},
+    ],
+)
+async def test_gauge_fastmcp_rejects_missing_temporal_comparator(
+    mcp_server: FastMCP,
+    filter_value: dict[str, Any],
+) -> None:
+    """Transport validation rejects malformed ranges before query compilation."""
+    from fastmcp.exceptions import ToolError
+
+    with patch.object(generate_explore_link_module, "validate_and_compile") as compile_:
+        async with Client(mcp_server) as client:
+            with pytest.raises(ToolError, match="requires a temporal comparator"):
+                await client.call_tool(
+                    "generate_explore_link",
+                    {
+                        "request": {
+                            "dataset_id": "3",
+                            "config": {
+                                "chart_type": "gauge",
+                                "metric": {"name": "num", "aggregate": "AVG"},
+                                "adhoc_filters": [filter_value],
+                            },
+                        }
+                    },
+                )
+        compile_.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_gauge_fastmcp_rejects_conflicting_temporal_filters(
+    mcp_server: FastMCP,
+) -> None:
+    """Public native-form input cannot silently lose the second time restriction."""
+    from fastmcp.exceptions import ToolError
+
+    temporal = {
+        "subject": "event_time",
+        "operator": "TEMPORAL_RANGE",
+        "comparator": "Last week",
+    }
+    with patch.object(generate_explore_link_module, "validate_and_compile") as compile_:
+        async with Client(mcp_server) as client:
+            with pytest.raises(
+                ToolError, match="conflicts with another temporal range"
+            ):
+                await client.call_tool(
+                    "generate_explore_link",
+                    {
+                        "request": {
+                            "dataset_id": "3",
+                            "config": {
+                                "chart_type": "gauge",
+                                "metric": "saved_sla",
+                                "adhoc_filters": [
+                                    temporal,
+                                    {**temporal, "subject": "other_time"},
+                                ],
+                            },
+                        }
+                    },
+                )
+        compile_.assert_not_called()