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/16 09:44:55 UTC

(superset) branch master updated: feat(mcp): add typed Gantt chart support (#43737)

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 0049f814072 feat(mcp): add typed Gantt chart support (#43737)
0049f814072 is described below

commit 0049f81407277e6065b90504181ce749637d3116
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Sep 16 19:44:48 2026 +1000

    feat(mcp): add typed Gantt chart support (#43737)
---
 .github/actions/setup-backend/action.yml           |    6 +-
 .github/actions/setup-docker/action.yml            |    3 +
 .github/workflows/superset-python-unittest.yml     |    3 +-
 .../docs/using-superset/using-ai-with-superset.mdx |   43 +
 superset/mcp_service/app.py                        |    5 +-
 superset/mcp_service/chart/chart_helpers.py        |  174 +-
 superset/mcp_service/chart/chart_utils.py          |  306 ++-
 superset/mcp_service/chart/plugins/__init__.py     |    3 +
 superset/mcp_service/chart/plugins/gantt.py        |  237 ++
 superset/mcp_service/chart/preview_utils.py        |  265 ++
 superset/mcp_service/chart/query_result.py         |    8 +-
 .../mcp_service/chart/resources/chart_configs.py   |   24 +
 superset/mcp_service/chart/schemas.py              |  803 +++++-
 superset/mcp_service/chart/tool/generate_chart.py  |   11 +-
 superset/mcp_service/chart/tool/get_chart_data.py  |    1 +
 .../mcp_service/chart/tool/get_chart_preview.py    |   50 +-
 .../chart/tool/get_chart_type_schema.py            |   15 +-
 superset/mcp_service/chart/tool/update_chart.py    |   37 +-
 .../mcp_service/chart/tool/update_chart_preview.py |   42 +
 .../chart/validation/dataset_validator.py          |   27 +-
 superset/mcp_service/chart/validation/pipeline.py  |   34 +-
 .../mcp_service/chart/test_chart_examples.py       |   59 +
 .../mcp_service/chart/test_chart_helpers.py        |  285 ++
 tests/unit_tests/mcp_service/chart/test_compile.py |   85 +
 .../mcp_service/chart/test_gantt_chart.py          | 2812 ++++++++++++++++++++
 .../mcp_service/chart/test_preview_utils.py        |  145 +-
 .../mcp_service/chart/test_query_result.py         |   71 +
 .../mcp_service/chart/tool/test_update_chart.py    |   56 +
 .../chart/tool/test_update_chart_preview.py        |   36 +
 .../migrations/test_single_migration_head.py       |   29 +
 30 files changed, 5562 insertions(+), 113 deletions(-)

diff --git a/.github/actions/setup-backend/action.yml b/.github/actions/setup-backend/action.yml
index c7bff2e3c1f..53cc27a0a6b 100644
--- a/.github/actions/setup-backend/action.yml
+++ b/.github/actions/setup-backend/action.yml
@@ -51,7 +51,11 @@ runs:
       # or package lookups silently resolve to an empty list.
       if: inputs.install-superset == 'true'
       shell: bash
-      run: sudo apt-get update
+      run: |
+        # Use the runner's preinstalled Chrome; these dependencies do not need
+        # its apt repository. Mirror index failures must not block their setup.
+        sudo rm -f /etc/apt/sources.list.d/google-chrome*.list /etc/apt/sources.list.d/google-chrome*.sources
+        sudo apt-get update
     - name: Install apt packages
       if: inputs.install-superset == 'true'
       uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml
index f2c5b8494b2..fcd56e8460d 100644
--- a/.github/actions/setup-docker/action.yml
+++ b/.github/actions/setup-docker/action.yml
@@ -53,6 +53,9 @@ runs:
       if: ${{ inputs.install-docker-compose == 'true' }}
       shell: bash
       run: |
+        # Use the runner's preinstalled Chrome; these dependencies do not need
+        # its apt repository. Mirror index failures must not block their setup.
+        sudo rm -f /etc/apt/sources.list.d/google-chrome*.list /etc/apt/sources.list.d/google-chrome*.sources
         sudo apt-get update
         sudo apt-get install -y ca-certificates curl
         sudo install -m 0755 -d /etc/apt/keyrings
diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml
index ae8cd2be6f1..9af2fdbaf12 100644
--- a/.github/workflows/superset-python-unittest.yml
+++ b/.github/workflows/superset-python-unittest.yml
@@ -43,7 +43,8 @@ jobs:
     needs: changes
     if: needs.changes.outputs.python == 'true'
     runs-on: ubuntu-26.04
-    timeout-minutes: 30
+    # Leave time for setup, the full suite, scoped coverage gates, and uploads.
+    timeout-minutes: 45
     permissions:
       id-token: write
     strategy:
diff --git a/docs/docs/using-superset/using-ai-with-superset.mdx b/docs/docs/using-superset/using-ai-with-superset.mdx
index 31132143cfc..b54f9f71cb9 100644
--- a/docs/docs/using-superset/using-ai-with-superset.mdx
+++ b/docs/docs/using-superset/using-ai-with-superset.mdx
@@ -342,3 +342,46 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
 - Verify the connection in your AI client (e.g., the hammer icon in Claude Desktop)
 - Ask the AI "What Superset tools are available?" to confirm the connection
 - Restart your AI client if you recently changed the configuration
+
+
+## Gantt charts through MCP
+
+Use `chart_type: "gantt"` with temporal `start_time` and `end_time` column
+references and a `category` task column. Optional `series` colors the intervals;
+`subcategories: true` separates tasks within each series. Ask
+`get_chart_type_schema` for the complete Gantt configuration and examples.
+
+Gantt Vega-Lite previews skip rows with null or invalid bounds, reversed intervals,
+or missing category values. An empty query result produces an empty preview;
+a nonempty result with no renderable intervals returns an error. Missing result
+fields and malformed result structures still return errors. Preview specifications
+contain at most 1,000 renderable rows and only fields used by the visualization.
+
+When updating a saved or cached Gantt chart, omitting `filters` preserves existing
+non-generated adhoc filters. Supplying `filters`, including `[]`, replaces them.
+The mapper-owned temporal binding is regenerated from the supplied configuration;
+an inactive native `No filter` placeholder is replaced by that binding. Unsupported
+merged filter state is rejected rather than silently discarded. Native sort entries
+must remain valid `[column, ascending_boolean]` pairs.
+
+The shared MCP query builder preserves explicit saved `orderby` values. For
+`mixed_timeseries`, primary ordering applies only to the primary query; secondary
+ordering uses `orderby_b` when provided. Typed Gantt `order_by` continues to map
+through native `order_by_cols`, independently of these saved query keys.
+
+The default `update_chart` preview-first flow validates the complete Gantt preview
+state, including its saved-chart envelope. The chart name (`slice_name`) is
+transport metadata, not a Gantt control; unknown visualization controls still fail
+validation.
+
+For same-dataset Gantt updates, omitted `tooltip_columns`, `tooltip_metrics`,
+`order_by`, and `row_limit` keep their saved values. Explicit empty lists clear
+those controls, and an explicitly supplied row limit replaces the saved limit.
+A dataset change does not inherit the old series, tooltips, ordering, or filters.
+
+A single Explore-authored temporal filter is retained when temporal settings are
+omitted, without adding a second generated binding. Explicit `temporal_column` or
+`time_range` replaces that native time predicate; explicit `filters`, including
+`[]`, retains its replacement semantics. Unsupported multiple temporal predicates
+remain validation errors. Empty strings in saved `x_axis_time_bounds` are treated
+as unset bounds, including a fully cleared range.
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 81cdaabcb47..4e0369bf79f 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -416,6 +416,9 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
    whisker_type: tukey | min_max | percentile)
 - chart_type="waterfall": Waterfall chart of cumulative increases/decreases
   (x_axis + metric required; optional single breakdown column, show_total)
+- chart_type="gantt": Gantt task intervals over time
+  (temporal start_time + end_time and category required; optional series,
+   tooltip columns/metrics, ordering, time range, and subcategories)
 
 Time grain for temporal x-axis (time_grain parameter):
 - PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly)
@@ -425,7 +428,7 @@ Each chart returned by list_charts / get_chart_info includes a
 chart_type_display_name field with a human-readable name when available.
 This field is populated for chart types known to the MCP registry
 (xy, pie, table, pivot_table, big_number, mixed_timeseries, handlebars,
-histogram, box_plot, waterfall, and interactive_pivot). Availability gates
+histogram, box_plot, waterfall, gantt, and interactive_pivot). Availability gates
 creation and schema discovery, not display names for existing charts.
 For all other viz_types (Funnel, Gauge, Heatmap, etc.) it will be null —
 use the raw viz_type field instead when referring to those chart types.
diff --git a/superset/mcp_service/chart/chart_helpers.py b/superset/mcp_service/chart/chart_helpers.py
index f76e3426f24..b661ed4971b 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -548,6 +548,123 @@ def resolve_metrics_and_groupby(
     return resolve_metrics(form_data, viz_type), resolve_groupby(form_data)
 
 
+def resolve_big_number_columns(form_data: dict[str, Any]) -> list[Any]:
+    """Resolve the temporal column used by Big Number with Trendline.
+
+    The frontend accepts the temporal binding through either ``x_axis`` or the
+    legacy ``granularity_sqla`` control. MCP preview and compile historically
+    used the physical granularity column when ``x_axis`` was absent; preserve
+    that contract rather than reducing every Big Number query to a total.
+
+    ``big_number_total`` intentionally does not use this helper because its
+    frontend query has no temporal dimension.
+    """
+    x_axis = form_data.get("x_axis")
+    if isinstance(x_axis, str) and x_axis:
+        return [x_axis]
+    if isinstance(x_axis, dict):
+        if (
+            isinstance(x_axis.get("sqlExpression"), str)
+            and x_axis.get("sqlExpression")
+            and isinstance(x_axis.get("label"), str)
+            and x_axis.get("label")
+            and x_axis.get("expressionType") in (None, "SQL")
+        ):
+            return [x_axis]
+        column_name = x_axis.get("column_name")
+        if isinstance(column_name, str) and column_name:
+            return [column_name]
+
+    granularity = form_data.get("granularity_sqla")
+    return [granularity] if isinstance(granularity, str) and granularity else []
+
+
+def resolve_gantt_query_fields(  # noqa: C901
+    form_data: dict[str, Any],
+) -> tuple[list[Any], list[Any], list[list[Any]], list[Any]]:
+    """Mirror the ECharts Gantt ``buildQuery`` field extraction contract.
+
+    Returns ``(columns, metrics, orderby, series_columns)``. Saved form data is
+    user-editable, so malformed or oversized native ordering is rejected rather
+    than silently dropped or passed into ``QueryContextFactory``.
+    """
+    from superset.utils import json as utils_json
+
+    def require_column(value: Any, field_name: str) -> Any:
+        if isinstance(value, str) and value:
+            return value
+        if isinstance(value, dict) and 0 < len(value) <= 20:
+            # QueryFormColumn objects use column_name for physical columns or
+            # expressionType/sqlExpression/label for adhoc columns.
+            if value.get("column_name") or (
+                value.get("expressionType") and value.get("label")
+            ):
+                return value
+        raise ValueError(f"Gantt {field_name} must be a column reference")
+
+    start_time = require_column(form_data.get("start_time"), "start_time")
+    end_time = require_column(form_data.get("end_time"), "end_time")
+    category = require_column(form_data.get("y_axis"), "y_axis")
+
+    raw_series = form_data.get("series")
+    series_columns = (
+        [require_column(raw_series, "series")] if raw_series is not None else []
+    )
+
+    raw_tooltip_columns = form_data.get("tooltip_columns") or []
+    raw_tooltip_metrics = form_data.get("tooltip_metrics") or []
+    if not isinstance(raw_tooltip_columns, list) or len(raw_tooltip_columns) > 50:
+        raise ValueError("Gantt tooltip_columns must contain at most 50 entries")
+    if not isinstance(raw_tooltip_metrics, list) or len(raw_tooltip_metrics) > 50:
+        raise ValueError("Gantt tooltip_metrics must contain at most 50 entries")
+    tooltip_columns = [
+        require_column(column, f"tooltip_columns[{index}]")
+        for index, column in enumerate(raw_tooltip_columns)
+    ]
+
+    raw_order = form_data.get("order_by_cols") or []
+    if not isinstance(raw_order, list) or len(raw_order) > 100:
+        raise ValueError("Gantt order_by_cols must contain at most 100 entries")
+    orderby: list[list[Any]] = []
+    for index, entry in enumerate(raw_order):
+        if isinstance(entry, str):
+            if len(entry) > 1000:
+                raise ValueError(f"Gantt order_by_cols[{index}] is too long")
+            try:
+                entry = utils_json.loads(entry)
+            except (TypeError, ValueError) as ex:
+                raise ValueError(
+                    f"Gantt order_by_cols[{index}] is not valid JSON"
+                ) from ex
+        if (
+            not isinstance(entry, (list, tuple))
+            or len(entry) != 2
+            or not isinstance(entry[0], str)
+            or not entry[0]
+            or not isinstance(entry[1], bool)
+        ):
+            raise ValueError(
+                f"Gantt order_by_cols[{index}] must be [column, ascending_boolean]"
+            )
+        orderby.append([entry[0], entry[1]])
+
+    columns: list[Any] = []
+    seen: set[str] = set()
+    for column in (
+        start_time,
+        end_time,
+        category,
+        *series_columns,
+        *tooltip_columns,
+        *(entry[0] for entry in orderby),
+    ):
+        key = utils_json.dumps(column, sort_keys=True, default=str)
+        if key not in seen:
+            seen.add(key)
+            columns.append(column)
+    return columns, list(raw_tooltip_metrics), orderby, series_columns
+
+
 def extract_x_axis_col(form_data: dict[str, Any]) -> str | None:
     """Return the x_axis column name from form_data, or None if not set."""
     x_axis = form_data.get("x_axis")
@@ -568,6 +685,9 @@ def _build_single_query_dict(
 ) -> dict[str, Any]:
     """Build one query entry for QueryContextFactory from form_data fields."""
     qd: dict[str, Any] = {"columns": columns, "metrics": metrics}
+    # Saved query-shaped ordering is distinct from typed sort_by/order_by_cols.
+    if form_data.get("orderby"):
+        qd["orderby"] = form_data["orderby"]
     effective_row_limit = row_limit
     if effective_row_limit is None:
         effective_row_limit = form_data.get("row_limit")
@@ -576,16 +696,50 @@ def _build_single_query_dict(
     if order_desc is not None:
         qd["order_desc"] = order_desc
     # sort_by_metric charts (pie/funnel/treemap/sankey/gauge) order by the
-    # metric descending. buildQuery derives this on the frontend; the MCP path
-    # builds the query dict directly and never reads a top-level
-    # form_data['orderby'], so translate the flag here or a row_limit truncates
+    # metric descending. buildQuery derives this on the frontend; translate
+    # the flag here when there is no explicit ordering or a row_limit truncates
     # an unordered result (dropping the heaviest rows rather than the top-N).
-    if form_data.get("sort_by_metric") and metrics:
+    if form_data.get("sort_by_metric") and metrics and not qd.get("orderby"):
         qd["orderby"] = [(metrics[0], False)]
     apply_form_data_filters_to_query(qd, form_data)
     return qd
 
 
+def _build_gantt_or_big_number_query_dicts(
+    form_data: dict[str, Any],
+    viz_type: str,
+    metrics: list[Any],
+    row_limit: int | None,
+    order_desc: bool | None,
+) -> list[dict[str, Any]] | None:
+    """Build query dictionaries for the two specialized MCP chart contracts."""
+    if viz_type == "gantt_chart":
+        columns, gantt_metrics, orderby, series_columns = resolve_gantt_query_fields(
+            form_data
+        )
+        query = _build_single_query_dict(
+            form_data,
+            columns,
+            gantt_metrics,
+            row_limit=row_limit,
+        )
+        query["orderby"] = orderby
+        query["series_columns"] = series_columns
+        return [query]
+
+    if viz_type == "big_number":
+        return [
+            _build_single_query_dict(
+                form_data,
+                resolve_big_number_columns(form_data),
+                metrics,
+                row_limit=row_limit,
+                order_desc=order_desc,
+            )
+        ]
+    return None
+
+
 def _build_mixed_timeseries_secondary(
     form_data: dict[str, Any],
     x_axis_col: str | None,
@@ -603,8 +757,9 @@ def _build_mixed_timeseries_secondary(
     if x_axis_col and x_axis_col not in groupby_b:
         groupby_b = [x_axis_col] + groupby_b
 
+    # Each series owns its ordering; primary metrics may not exist in query B.
     qd = _build_single_query_dict(
-        form_data,
+        {**form_data, "orderby": form_data.get("orderby_b")},
         groupby_b,
         metrics_b,
         row_limit=row_limit,
@@ -662,6 +817,15 @@ def build_query_dicts_from_form_data(
         or ""
     )
 
+    if specialized_queries := _build_gantt_or_big_number_query_dicts(
+        form_data,
+        viz_type,
+        metrics,
+        row_limit,
+        order_desc,
+    ):
+        return specialized_queries
+
     # Deck.gl charts use spatial column configs rather than the standard
     # metrics / groupby fields. Extract columns from the spatial controls.
     if viz_type.startswith("deck_"):
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index a9934d7727b..f82276a2fc7 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -28,6 +28,7 @@ from collections.abc import Mapping
 from dataclasses import dataclass
 from typing import Any, Dict, TYPE_CHECKING
 
+from pydantic import ValidationError
 from sqlalchemy.exc import SQLAlchemyError
 
 if TYPE_CHECKING:
@@ -43,6 +44,7 @@ from superset.mcp_service.chart.schemas import (
     ColumnRef,
     CurrencyFormat,
     FilterConfig,
+    GanttChartConfig,
     GaugeChartConfig,
     HandlebarsChartConfig,
     HistogramChartConfig,
@@ -58,6 +60,7 @@ from superset.mcp_service.chart.schemas import (
 from superset.mcp_service.chart.validation.dataset_validator import (
     is_dataset_column_temporal,
 )
+from superset.mcp_service.common.error_schemas import DatasetContext
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
 from superset.utils.core import FilterOperator
@@ -600,6 +603,29 @@ def merge_interactive_pivot_ui_config(
         new_form_data["pivot_table_state"] = {**existing_state, **new_state}
 
 
+_GANTT_PRESENTATION_KEYS = frozenset(
+    {
+        "color_scheme",
+        "legendMargin",
+        "legendOrientation",
+        "legendSort",
+        "legendType",
+        "show_extra_controls",
+        "show_legend",
+        "subcategories",
+        "tooltipTimeFormat",
+        "tooltipValuesFormat",
+        "x_axis_time_bounds",
+        "x_axis_time_format",
+        "x_axis_title",
+        "x_axis_title_margin",
+        "y_axis_title",
+        "y_axis_title_margin",
+        "zoomable",
+    }
+)
+
+
 _GAUGE_FORM_DATA_FIELD_MAP: dict[str, str] = {
     "groupby": "groupby",
     "sort_by_metric": "sort_by_metric",
@@ -652,6 +678,164 @@ _GAUGE_PRESENTATION_FORM_DATA_KEYS = frozenset(
 )
 
 
+def validate_gantt_form_data(
+    form_data: Mapping[str, Any],
+    dataset_id: int | str | None = None,
+    dataset_context: DatasetContext | None = None,
+) -> GanttChartConfig | None:
+    """Adapt and validate final native Gantt state after presentation merging.
+
+    Update requests are typed before native UI state is preserved, so validating
+    only the request can miss conflicts introduced by the merge. When dataset
+    metadata is available, run the adapted final state through canonical column
+    resolution as well; this catches aliases that resolve to the same physical
+    column rather than comparing native strings in isolation.
+    """
+    if form_data.get("viz_type") != "gantt_chart":
+        return None
+
+    from superset.mcp_service.chart.validation.dataset_validator import (
+        DatasetValidator,
+        GanttSemanticNormalizationError,
+    )
+
+    try:
+        config = GanttChartConfig.model_validate(dict(form_data))
+    except ValidationError as ex:
+        reasons = "; ".join(error["msg"] for error in ex.errors()[:3])
+        raise GanttSemanticNormalizationError(
+            f"Merged Gantt form data is invalid: {reasons}"
+        ) from ex
+
+    if dataset_id is None:
+        return config
+    return DatasetValidator.normalize_column_names(
+        config,
+        dataset_id,
+        dataset_context=dataset_context,
+    )
+
+
+def _preserve_gantt_adhoc_filters(
+    new_form_data: dict[str, Any],
+    previous_form_data: dict[str, Any],
+    config: GanttChartConfig,
+) -> None:
+    """Reconcile Gantt's single time binding while preserving omitted predicates."""
+    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", [])
+    # The marker identifies a temporal subject, not ownership of its current
+    # comparator: Explore edits and earlier MCP updates may set an active range
+    # on that same subject. Only discard inactive placeholders here; reconcile
+    # active predicates below using the fields explicitly supplied by the caller.
+    merged_filters = [
+        filter_
+        for filter_ in previous_filters
+        if not (
+            isinstance(filter_, dict)
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+            and new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+            and filter_.get("comparator") == NO_TIME_RANGE
+            and filter_.get("expressionType") == "SIMPLE"
+            and filter_.get("clause") == "WHERE"
+        )
+    ]
+    native_temporal = [
+        filter_
+        for filter_ in merged_filters
+        if isinstance(filter_, dict)
+        and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+        and filter_.get("expressionType") == "SIMPLE"
+        and filter_.get("clause") == "WHERE"
+    ]
+    if len(native_temporal) == 1:
+        if {"temporal_column", "time_range"} & config.model_fields_set:
+            # An explicit temporal update replaces the one native time predicate.
+            merged_filters.remove(native_temporal[0])
+        else:
+            # Keep an Explore-authored range instead of adding a second binding.
+            generated_filters = [
+                filter_
+                for filter_ in generated_filters
+                if not (
+                    isinstance(filter_, dict)
+                    and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+                )
+            ]
+            # The retained predicate is user-owned, not a generated binding.
+            new_form_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+    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
+
+        # Replace an equivalent cached filter rather than deduplicating it: an
+        # unchanged temporal subject can still have a newly supplied comparator.
+        merged_filters = [
+            previous_filter
+            for previous_filter in merged_filters
+            if not (
+                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")
+            )
+        ]
+        merged_filters.append(generated_filter)
+
+    new_form_data["adhoc_filters"] = merged_filters
+
+
+def merge_gantt_ui_config(
+    previous_form_data: Mapping[str, Any], new_form_data: Dict[str, Any]
+) -> GanttChartConfig | None:
+    """Preserve omitted native Gantt presentation and dependent series state.
+
+    ``subcategories`` is only meaningful with ``series``. An omitted pair is
+    preserved together, an explicit series replacement keeps an omitted valid
+    subcategory setting, and explicit series removal disables subcategories.
+    """
+    if (
+        previous_form_data.get("viz_type") != "gantt_chart"
+        or new_form_data.get("viz_type") != "gantt_chart"
+    ):
+        return None
+    series_was_supplied = "series" in new_form_data
+    subcategories_was_supplied = "subcategories" in new_form_data
+
+    if not series_was_supplied and "series" in previous_form_data:
+        new_form_data["series"] = previous_form_data["series"]
+
+    for key in _GANTT_PRESENTATION_KEYS:
+        if key not in new_form_data and key in previous_form_data:
+            new_form_data[key] = previous_form_data[key]
+
+    if not new_form_data.get("series") and new_form_data.get("subcategories"):
+        # Explicit series removal wins over an omitted or stale presentation
+        # value. Persisting subcategories=True without series cannot be rendered
+        # coherently by the frontend.
+        new_form_data["subcategories"] = False
+    elif (
+        not series_was_supplied
+        and not subcategories_was_supplied
+        and not new_form_data.get("series")
+    ):
+        # Do not carry forward an already-inconsistent native payload.
+        new_form_data.pop("subcategories", None)
+
+    # Every caller gets the same post-merge semantic gate. Product paths run
+    # this helper again with dataset metadata before query, cache, or persistence
+    # so physical aliases are resolved canonically as well.
+    return validate_gantt_form_data(new_form_data)
+
+
 def _without_generated_gauge_time_filter(
     form_data: dict[str, Any],
 ) -> list[Any]:
@@ -687,6 +871,24 @@ def merge_chart_form_data(  # noqa: C901
     """
     if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
         return dict(new_form_data)
+    if isinstance(config, GanttChartConfig):
+        merged = dict(new_form_data)
+        if not dataset_rebind:
+            for config_field, form_key in (
+                ("tooltip_columns", "tooltip_columns"),
+                ("tooltip_metrics", "tooltip_metrics"),
+                ("order_by", "order_by_cols"),
+                ("row_limit", "row_limit"),
+            ):
+                if (
+                    config_field not in config.model_fields_set
+                    and form_key in existing_form_data
+                ):
+                    merged[form_key] = existing_form_data[form_key]
+            merge_gantt_ui_config(existing_form_data, merged)
+            if config.filters is None:
+                _preserve_gantt_adhoc_filters(merged, existing_form_data, config)
+        return merged
     if not isinstance(config, GaugeChartConfig):
         if dataset_rebind:
             return dict(new_form_data)
@@ -986,12 +1188,33 @@ def _ensure_generated_temporal_binding(form_data: Dict[str, Any], column: str) -
         form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = column
 
 
-def _bind_dashboard_time_range_filter(
+def _uses_mapper_owned_temporal_binding(
+    form_data: Dict[str, Any], dataset_id: int | str | None
+) -> bool:
+    """Whether a mapper supplied a validated natural time-filter binding."""
+    existing_binding = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    if not isinstance(existing_binding, str) or not any(
+        isinstance(filter_, dict)
+        and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+        and filter_.get("subject") == existing_binding
+        for filter_ in form_data.get("adhoc_filters", [])
+    ):
+        return False
+    # Mappers such as Gantt own their natural time field even though it is
+    # neither x_axis nor granularity_sqla. Validate the physical type rather
+    # than trusting the internal marker alone.
+    return _is_temporal_for_dashboard_binding(existing_binding, dataset_id)
+
+
+def _bind_dashboard_time_range_filter(  # noqa: C901
     form_data: Dict[str, Any],
     config: ChartConfig,
     dataset_id: int | str | None,
 ) -> None:
     """Bind charts without time configuration to a temporal filter subject."""
+    if _uses_mapper_owned_temporal_binding(form_data, dataset_id):
+        return
+
     if temporal_column := getattr(config, "temporal_column", None):
         if _is_temporal_for_dashboard_binding(temporal_column, dataset_id):
             granularity = form_data.get("granularity_sqla")
@@ -1373,6 +1596,87 @@ def map_waterfall_config(config: WaterfallChartConfig) -> Dict[str, Any]:
     return form_data
 
 
+def map_gantt_config(config: GanttChartConfig) -> Dict[str, Any]:
+    """Map typed Gantt config to the exact ECharts Gantt form-data contract."""
+    form_data: Dict[str, Any] = {
+        "viz_type": "gantt_chart",
+        "start_time": config.start_time.name,
+        "end_time": config.end_time.name,
+        "y_axis": config.category.name,
+        "tooltip_columns": [column.name for column in config.tooltip_columns],
+        "tooltip_metrics": [
+            create_metric_object(metric) for metric in config.tooltip_metrics
+        ],
+        "order_by_cols": [
+            json.dumps([order.column, order.ascending]) for order in config.order_by
+        ],
+        "row_limit": config.row_limit,
+    }
+    if config.series is not None:
+        form_data["series"] = config.series.name
+    elif "series" in config.model_fields_set:
+        # Preserve explicit removal intent for update merges. Native Gantt treats
+        # null the same as an absent series control.
+        form_data["series"] = None
+    if config.time_range is not None:
+        form_data["time_range"] = config.time_range
+
+    # Only explicitly supplied presentation fields are persisted. Update paths
+    # merge native values for omitted controls; fresh charts use frontend defaults.
+    presentation_fields = {
+        "color_scheme": ("color_scheme", config.color_scheme),
+        "show_legend": ("show_legend", config.show_legend),
+        "legend_orientation": ("legendOrientation", config.legend_orientation),
+        "legend_type": ("legendType", config.legend_type),
+        "legend_margin": ("legendMargin", config.legend_margin),
+        "legend_sort": ("legendSort", config.legend_sort),
+        "zoomable": ("zoomable", config.zoomable),
+        "subcategories": ("subcategories", config.subcategories),
+        "show_extra_controls": (
+            "show_extra_controls",
+            config.show_extra_controls,
+        ),
+        "x_axis_time_bounds": (
+            "x_axis_time_bounds",
+            list(config.x_axis_time_bounds) if config.x_axis_time_bounds else None,
+        ),
+        "x_axis_time_format": ("x_axis_time_format", config.x_axis_time_format),
+        "tooltip_time_format": ("tooltipTimeFormat", config.tooltip_time_format),
+        "tooltip_values_format": (
+            "tooltipValuesFormat",
+            config.tooltip_values_format,
+        ),
+        "x_axis_title": ("x_axis_title", config.x_axis_title),
+        "x_axis_title_margin": (
+            "x_axis_title_margin",
+            config.x_axis_title_margin,
+        ),
+        "y_axis_title": ("y_axis_title", config.y_axis_title),
+        "y_axis_title_margin": (
+            "y_axis_title_margin",
+            config.y_axis_title_margin,
+        ),
+    }
+    for field_name, (form_key, value) in presentation_fields.items():
+        if field_name in config.model_fields_set:
+            form_data[form_key] = value
+
+    _add_adhoc_filters(form_data, config.filters)
+    temporal_binding = config.temporal_column or config.start_time.name
+    if temporal_binding:
+        _ensure_generated_temporal_binding(form_data, temporal_binding)
+        if config.time_range:
+            for filter_ in form_data.get("adhoc_filters", []):
+                if (
+                    isinstance(filter_, dict)
+                    and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+                    and filter_.get("subject") == temporal_binding
+                    and filter_.get("comparator") == NO_TIME_RANGE
+                ):
+                    filter_["comparator"] = config.time_range
+    return form_data
+
+
 def map_big_number_config(
     config: BigNumberChartConfig, dataset_id: int | str | None = None
 ) -> Dict[str, Any]:
diff --git a/superset/mcp_service/chart/plugins/__init__.py b/superset/mcp_service/chart/plugins/__init__.py
index 247d265be8f..a0c9f67cc6a 100644
--- a/superset/mcp_service/chart/plugins/__init__.py
+++ b/superset/mcp_service/chart/plugins/__init__.py
@@ -29,6 +29,7 @@ To add a new chart type:
 
 from superset.mcp_service.chart.plugins.big_number import BigNumberChartPlugin
 from superset.mcp_service.chart.plugins.box_plot import BoxPlotChartPlugin
+from superset.mcp_service.chart.plugins.gantt import GanttChartPlugin
 from superset.mcp_service.chart.plugins.gauge import GaugeChartPlugin
 from superset.mcp_service.chart.plugins.handlebars import HandlebarsChartPlugin
 from superset.mcp_service.chart.plugins.histogram import HistogramChartPlugin
@@ -60,10 +61,12 @@ register(BigNumberChartPlugin())
 register(HistogramChartPlugin())
 register(BoxPlotChartPlugin())
 register(WaterfallChartPlugin())
+register(GanttChartPlugin())
 
 __all__ = [
     "BigNumberChartPlugin",
     "BoxPlotChartPlugin",
+    "GanttChartPlugin",
     "GaugeChartPlugin",
     "HandlebarsChartPlugin",
     "HistogramChartPlugin",
diff --git a/superset/mcp_service/chart/plugins/gantt.py b/superset/mcp_service/chart/plugins/gantt.py
new file mode 100644
index 00000000000..188654729ca
--- /dev/null
+++ b/superset/mcp_service/chart/plugins/gantt.py
@@ -0,0 +1,237 @@
+# 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.
+
+"""ECharts Gantt chart type plugin."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, ClassVar
+
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.chart_utils import _summarize_filters, map_gantt_config
+from superset.mcp_service.chart.plugin import BaseChartPlugin
+from superset.mcp_service.chart.schemas import ColumnRef, GanttChartConfig
+from superset.mcp_service.chart.validation.dataset_validator import (
+    AmbiguousDatasetReferenceError,
+    DatasetValidator,
+    GanttSemanticNormalizationError,
+    resolve_dataset_reference,
+)
+from superset.mcp_service.common.error_schemas import ChartGenerationError
+
+
+class GanttChartPlugin(BaseChartPlugin):
+    """Plugin matching ``plugin-chart-echarts/src/Gantt``."""
+
+    chart_type = "gantt"
+    display_name = "Gantt Chart"
+    native_viz_types: ClassVar[Mapping[str, str]] = {
+        "gantt_chart": "Gantt Chart",
+    }
+
+    def pre_validate(self, config: dict[str, Any]) -> ChartGenerationError | None:
+        aliases = {
+            "start_time": ("start_time", "startTime", "start"),
+            "end_time": ("end_time", "endTime", "end"),
+            "category": ("category", "task", "y_axis", "yAxis"),
+        }
+        missing = [
+            name
+            for name, keys in aliases.items()
+            if not any(key in config for key in keys)
+        ]
+        if not missing:
+            return None
+        return ChartGenerationError(
+            error_type="missing_gantt_fields",
+            message=f"Gantt chart missing required fields: {', '.join(missing)}",
+            details=(
+                "A Gantt task needs temporal start/end columns and one task/category "
+                "dimension (the frontend's y_axis control)."
+            ),
+            suggestions=[
+                "Add start_time: {'name': 'start_time'}",
+                "Add end_time: {'name': 'end_time'}",
+                "Add category: {'name': 'task_name'}",
+            ],
+            error_code="MISSING_GANTT_FIELDS",
+        )
+
+    def extract_column_refs(self, config: Any) -> list[ColumnRef]:
+        if not isinstance(config, GanttChartConfig):
+            return []
+        refs = [config.start_time, config.end_time, config.category]
+        if config.series:
+            refs.append(config.series)
+        refs.extend(config.tooltip_columns)
+        refs.extend(config.tooltip_metrics)
+        refs.extend(ColumnRef(name=order.column) for order in config.order_by)
+        refs.extend(ColumnRef(name=filter_.column) for filter_ in config.filters or [])
+        return refs
+
+    def to_form_data(
+        self, config: Any, dataset_id: int | str | None = None
+    ) -> dict[str, Any]:
+        return map_gantt_config(config)
+
+    def post_map_validate(
+        self,
+        config: Any,
+        form_data: dict[str, Any],
+        dataset_id: int | str | None = None,
+    ) -> ChartGenerationError | None:
+        """Enforce the temporal controls declared by the frontend contract."""
+        if not isinstance(config, GanttChartConfig) or dataset_id is None:
+            return None
+        dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+        if dataset_context is None:
+            return None
+
+        non_temporal: list[str] = []
+        for ref in (config.start_time, config.end_time):
+            try:
+                resolved_name = resolve_dataset_reference(
+                    ref.name or "",
+                    (column["name"] for column in dataset_context.available_columns),
+                    "physical column",
+                )
+            except AmbiguousDatasetReferenceError as ex:
+                return DatasetValidator._build_ambiguous_reference_error(ex)
+            column = next(
+                (
+                    column
+                    for column in dataset_context.available_columns
+                    if column["name"] == resolved_name
+                ),
+                None,
+            )
+            # Existence validation owns missing columns; avoid double-reporting.
+            if column is not None and not column.get("is_temporal", False):
+                non_temporal.append(ref.name or "")
+
+        if not non_temporal:
+            return None
+        temporal_columns = sorted(
+            column["name"]
+            for column in dataset_context.available_columns
+            if column.get("is_temporal", False)
+        )
+        return ChartGenerationError(
+            error_type="non_temporal_gantt_time_column",
+            message=(
+                "Gantt start_time and end_time must be temporal columns; "
+                f"non-temporal: {', '.join(non_temporal)}"
+            ),
+            details=(
+                "The Explore control panel restricts both fields to Temporal. "
+                "Using strings or numeric IDs would produce invalid task intervals."
+            ),
+            suggestions=(
+                [f"Temporal columns in this dataset: {', '.join(temporal_columns)}"]
+                if temporal_columns
+                else ["Use get_dataset_info to inspect temporal dataset columns"]
+            ),
+            error_code="NON_TEMPORAL_GANTT_TIME_COLUMN",
+        )
+
+    def normalize_column_refs(  # noqa: C901
+        self, config: Any, dataset_context: Any
+    ) -> Any:
+        explicit_fields = set(config.model_fields_set)
+        config_dict = config.model_dump()
+        column_names = [column["name"] for column in dataset_context.available_columns]
+        metric_names = [metric["name"] for metric in dataset_context.available_metrics]
+
+        def canonical_column(name: str, _context: Any) -> str:
+            try:
+                return (
+                    resolve_dataset_reference(name, column_names, "physical column")
+                    or name
+                )
+            except AmbiguousDatasetReferenceError as ex:
+                raise GanttSemanticNormalizationError(f"Gantt {ex}") from ex
+
+        def canonical_metric(name: str, _context: Any) -> str:
+            try:
+                return (
+                    resolve_dataset_reference(name, metric_names, "saved metric")
+                    or name
+                )
+            except AmbiguousDatasetReferenceError as ex:
+                raise GanttSemanticNormalizationError(f"Gantt {ex}") from ex
+
+        if temporal_column := config_dict.get("temporal_column"):
+            config_dict["temporal_column"] = canonical_column(
+                temporal_column, dataset_context
+            )
+        for key in ("start_time", "end_time", "category", "series"):
+            if ref := config_dict.get(key):
+                ref["name"] = canonical_column(ref["name"], dataset_context)
+        for ref in config_dict.get("tooltip_columns") or []:
+            ref["name"] = canonical_column(ref["name"], dataset_context)
+        for ref in config_dict.get("tooltip_metrics") or []:
+            if ref.get("sql_expression"):
+                continue
+            resolver = canonical_metric if ref.get("saved_metric") else canonical_column
+            ref["name"] = resolver(ref["name"], dataset_context)
+        for order in config_dict.get("order_by") or []:
+            order["column"] = canonical_column(order["column"], dataset_context)
+        DatasetValidator.normalize_filters(config_dict, dataset_context)
+        try:
+            normalized = GanttChartConfig.model_validate(config_dict)
+        except ValidationError as ex:
+            reasons = "; ".join(error["msg"] for error in ex.errors()[:3])
+            raise GanttSemanticNormalizationError(
+                "Gantt column normalization produced an invalid semantic "
+                f"configuration: {reasons}"
+            ) from ex
+        # ``model_dump()`` necessarily contains defaults, but mapping relies on
+        # ``model_fields_set`` to distinguish omitted presentation controls from
+        # explicitly supplied default values. Keep that request-level intent
+        # after canonicalizing column names.
+        normalized.model_fields_set.clear()
+        normalized.model_fields_set.update(explicit_fields)
+        return normalized
+
+    def generate_name(self, config: Any, dataset_name: str | None = None) -> str:
+        category = config.category.label or config.category.name
+        what = f"{category} schedule"
+        if config.series:
+            what += f" by {config.series.label or config.series.name}"
+        return self._with_context(what, _summarize_filters(config.filters))
+
+    def resolve_viz_type(self, config: Any) -> str:
+        return "gantt_chart"
+
+    def schema_error_hint(self) -> ChartGenerationError | None:
+        return ChartGenerationError(
+            error_type="gantt_validation_error",
+            message="Gantt chart configuration validation failed",
+            details=(
+                "Gantt requires distinct temporal start/end columns and one "
+                "task/category dimension."
+            ),
+            suggestions=[
+                "Use start_time and end_time ColumnRefs for temporal columns",
+                "Use category (aliases: task, y_axis) for the task row dimension",
+                "tooltip_metrics must be aggregate or saved metrics",
+                "Use series to color tasks; subcategories=True requires series",
+            ],
+            error_code="GANTT_VALIDATION_ERROR",
+        )
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index 09f19c6439a..db7f96010dd 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -25,6 +25,7 @@ from form data without requiring a saved chart object.
 import logging
 import math
 from copy import deepcopy
+from datetime import date, datetime, time, timezone
 from typing import Any, Dict, List
 
 from superset.mcp_service.chart.query_result import (
@@ -81,6 +82,8 @@ def generate_preview_from_form_data(
                 error=f"Dataset {dataset_id} not found", error_type="DatasetNotFound"
             )
 
+        # Create query context through the chart-aware shared builder used by
+        # saved/cached get_chart_data and compile validation.
         from superset.mcp_service.chart.chart_helpers import (
             build_query_context_from_form_data,
         )
@@ -102,6 +105,7 @@ def generate_preview_from_form_data(
 
         if query_failure := query_result_failure(result):
             return query_failure
+
         result = normalize_gauge_query_result(result, form_data)
         if isinstance(result, ChartError):
             return result
@@ -460,6 +464,265 @@ def _is_nan(value: Any) -> bool:
         return False
 
 
+def _gantt_metric_label(metric: Any) -> str | None:
+    """Resolve metric labels, accepting native columnName/column_name objects."""
+    if isinstance(metric, str) and metric:
+        return metric
+    if not isinstance(metric, dict) or not 0 < len(metric) <= 20:
+        return None
+
+    label = metric.get("label")
+    if label:
+        return label if isinstance(label, str) else None
+    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 aggregate
+            or len(aggregate) > 100
+            or not isinstance(column, dict)
+            or not 0 < len(column) <= 50
+        ):
+            return None
+        column_name = column.get("columnName") or column.get("column_name")
+        if not isinstance(column_name, str) or not column_name:
+            return None
+        return f"{aggregate}({column_name})"
+    if expression_type == "SQL":
+        sql_expression = metric.get("sqlExpression")
+        if (
+            isinstance(sql_expression, str)
+            and sql_expression
+            and len(sql_expression) <= 2000
+        ):
+            return sql_expression
+    return None
+
+
+def _gantt_result_field(field: Any, _field_name: str) -> str | None:
+    """Use frontend label/SQL precedence, then native physical column_name."""
+    if isinstance(field, str) and field:
+        return field
+    if not isinstance(field, dict) or not 0 < len(field) <= 20:
+        return None
+    label = field.get("label") or field.get("sqlExpression") or field.get("column_name")
+    return label if isinstance(label, str) and label else None
+
+
+def _gantt_temporal_value(value: Any) -> float | None:  # noqa: C901
+    """Return a comparable millisecond timestamp for one bounded date value."""
+    if value is None or isinstance(value, bool):
+        return None
+    if isinstance(value, (int, float)):
+        try:
+            converted = float(value)
+        except OverflowError:
+            return None
+        return converted if math.isfinite(converted) else None
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, date):
+        parsed = datetime.combine(value, time.min)
+    elif isinstance(value, str) and 0 < len(value) <= 128:
+        try:
+            parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+        except ValueError:
+            try:
+                parsed = datetime.combine(date.fromisoformat(value), time.min)
+            except ValueError:
+                return None
+    else:
+        return None
+    if parsed.tzinfo is None:
+        parsed = parsed.replace(tzinfo=timezone.utc)
+    try:
+        return parsed.timestamp() * 1000
+    except (OverflowError, OSError, ValueError):
+        return None
+
+
+def _is_valid_gantt_category(value: Any) -> bool:
+    """Return whether a category can produce a stable, visible nominal label."""
+    if value is None or isinstance(value, bool):
+        return False
+    if isinstance(value, str):
+        return bool(value.strip())
+    if isinstance(value, (int, float)):
+        try:
+            return math.isfinite(float(value))
+        except OverflowError:
+            return False
+    return False
+
+
+def _generate_gantt_vega_lite_preview(  # noqa: C901
+    data: Any, form_data: Dict[str, Any]
+) -> VegaLitePreview | ChartError:
+    """Build a bounded preview from renderable intervals, checking result shape."""
+    if not isinstance(data, list):
+        return ChartError(
+            error="Gantt result data is not an array of rows.",
+            error_type="InvalidGanttResult",
+        )
+    start = _gantt_result_field(form_data.get("start_time"), "start_time")
+    end = _gantt_result_field(form_data.get("end_time"), "end_time")
+    category = _gantt_result_field(form_data.get("y_axis"), "y_axis")
+    if not all((start, end, category)):
+        return ChartError(
+            error=(
+                "Gantt Vega-Lite preview requires valid start_time, end_time, "
+                "and y_axis form-data column references."
+            ),
+            error_type="InvalidGanttFormData",
+        )
+    assert isinstance(start, str)
+    assert isinstance(end, str)
+    assert isinstance(category, str)
+    if len({start.casefold(), end.casefold(), category.casefold()}) != 3:
+        return ChartError(
+            error=(
+                "Gantt start_time, end_time, and y_axis must resolve to distinct "
+                "query-result fields."
+            ),
+            error_type="InvalidGanttFormData",
+        )
+
+    series_value = form_data.get("series")
+    series = (
+        _gantt_result_field(series_value, "series")
+        if series_value is not None
+        else None
+    )
+    if series_value is not None and series is None:
+        return ChartError(
+            error="Gantt series must be a valid column reference.",
+            error_type="InvalidGanttFormData",
+        )
+
+    raw_tooltip_columns = form_data.get("tooltip_columns") or []
+    raw_tooltip_metrics = form_data.get("tooltip_metrics") or []
+    if not isinstance(raw_tooltip_columns, list) or len(raw_tooltip_columns) > 50:
+        return ChartError(
+            error="Gantt tooltip_columns must contain at most 50 column references.",
+            error_type="InvalidGanttFormData",
+        )
+    if not isinstance(raw_tooltip_metrics, list) or len(raw_tooltip_metrics) > 50:
+        return ChartError(
+            error="Gantt tooltip_metrics must contain at most 50 metric references.",
+            error_type="InvalidGanttFormData",
+        )
+    tooltip_columns: list[str] = []
+    for index, field in enumerate(raw_tooltip_columns):
+        label = _gantt_result_field(field, f"tooltip_columns[{index}]")
+        if label is None:
+            return ChartError(
+                error=(
+                    f"Gantt tooltip_columns[{index}] must be a valid column reference."
+                ),
+                error_type="InvalidGanttFormData",
+            )
+        tooltip_columns.append(label)
+    tooltip_metrics: list[str] = []
+    for index, metric in enumerate(raw_tooltip_metrics):
+        label = _gantt_metric_label(metric)
+        if label is None:
+            return ChartError(
+                error=(
+                    f"Gantt tooltip_metrics[{index}] must expose a non-empty "
+                    "result label."
+                ),
+                error_type="InvalidGanttFormData",
+            )
+        tooltip_metrics.append(label)
+
+    required = {start, end, category, *tooltip_columns, *tooltip_metrics}
+    if series:
+        required.add(series)
+    renderable_rows: list[dict[str, Any]] = []
+    for index, row in enumerate(data):
+        if not isinstance(row, dict):
+            return ChartError(
+                error=f"Gantt result row {index} is not an object.",
+                error_type="InvalidGanttResult",
+            )
+        missing = required - row.keys()
+        if missing:
+            return ChartError(
+                error=(
+                    f"Gantt result row {index} is missing required output fields: "
+                    f"{', '.join(sorted(missing))}."
+                ),
+                error_type="InvalidGanttResult",
+            )
+        start_value = _gantt_temporal_value(row[start])
+        end_value = _gantt_temporal_value(row[end])
+        if (
+            start_value is None
+            or end_value is None
+            or end_value < start_value
+            or not _is_valid_gantt_category(row[category])
+        ):
+            continue
+        # Limit the LLM-facing payload and omit fields unused by the preview.
+        if len(renderable_rows) < 1000:
+            renderable_rows.append({field: row[field] for field in sorted(required)})
+
+    if data and not renderable_rows:
+        return ChartError(
+            error="Gantt result contains no renderable intervals.",
+            error_type="InvalidGanttResult",
+        )
+
+    tooltip_fields: list[dict[str, str]] = [
+        {"field": category, "type": "nominal"},
+        {"field": start, "type": "temporal"},
+        {"field": end, "type": "temporal"},
+    ]
+    for field in ([series] if series else []) + tooltip_columns:
+        if isinstance(field, str) and field not in {
+            item["field"] for item in tooltip_fields
+        }:
+            tooltip_fields.append({"field": field, "type": "nominal"})
+    for label in tooltip_metrics:
+        if label not in {item["field"] for item in tooltip_fields}:
+            tooltip_fields.append({"field": label, "type": "quantitative"})
+
+    encoding: dict[str, Any] = {
+        "x": {
+            "field": start,
+            "type": "temporal",
+            "title": form_data.get("x_axis_title"),
+        },
+        "x2": {"field": end},
+        "y": {
+            "field": category,
+            "type": "nominal",
+            "title": form_data.get("y_axis_title") or category,
+        },
+        "tooltip": tooltip_fields,
+    }
+    if series:
+        encoding["color"] = {"field": series, "type": "nominal", "title": series}
+        if form_data.get("subcategories"):
+            encoding["yOffset"] = {"field": series, "type": "nominal"}
+
+    return VegaLitePreview(
+        specification={
+            "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+            "data": {"values": renderable_rows},
+            "mark": {"type": "bar", "tooltip": True},
+            "encoding": encoding,
+            "width": "container",
+            "height": 400,
+        },
+    )
+
+
 _GAUGE_COLORS = (
     "#1FA8C9",
     "#454E7C",
@@ -933,6 +1196,8 @@ def _generate_vega_lite_preview_from_data(  # noqa: C901
 ) -> VegaLitePreview | ChartError:
     """Generate Vega-Lite preview from raw data and form_data."""
     viz_type = form_data.get("viz_type", "table")
+    if viz_type == "gantt_chart":
+        return _generate_gantt_vega_lite_preview(data, form_data)
     if viz_type == "gauge_chart":
         return generate_gauge_vega_lite_preview(data, form_data)
 
diff --git a/superset/mcp_service/chart/query_result.py b/superset/mcp_service/chart/query_result.py
index bc468be316a..16c01122d16 100644
--- a/superset/mcp_service/chart/query_result.py
+++ b/superset/mcp_service/chart/query_result.py
@@ -78,7 +78,13 @@ def _failure_for_query_payload(
 
 
 def query_result_failure(result: Any) -> ChartError | None:
-    """Return a structured failure embedded in a ChartDataCommand payload."""
+    """Return a structured failure embedded in a ChartDataCommand payload.
+
+    ChartDataCommand can return an HTTP-successful envelope whose top level or
+    any query reports a failure. Every query is inspected before callers accept
+    data from the result. Successful statuses may carry informational messages,
+    so ``message`` alone is not treated as an error.
+    """
     if not isinstance(result, Mapping):
         return None
 
diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py
index c76046ae97a..4a0ac58c89b 100644
--- a/superset/mcp_service/chart/resources/chart_configs.py
+++ b/superset/mcp_service/chart/resources/chart_configs.py
@@ -230,6 +230,23 @@ def get_chart_configs_resource() -> str:
         },
     }
 
+    gantt_chart_configs = {
+        "project_schedule": {
+            "description": "Task intervals grouped by owner",
+            "config": {
+                "chart_type": "gantt",
+                "start_time": {"name": "start_time"},
+                "end_time": {"name": "end_time"},
+                "category": {"name": "task_name"},
+                "series": {"name": "owner"},
+                "tooltip_columns": [{"name": "project"}],
+                "order_by": [{"column": "start_time", "ascending": True}],
+                "subcategories": True,
+            },
+            "use_cases": ["Project schedules", "Task and phase timelines"],
+        },
+    }
+
     from superset.mcp_service.chart.registry import get_registry
 
     interactive_pivot_configs = {}
@@ -302,6 +319,12 @@ 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",
         ],
+        "gantt_charts": [
+            "Use temporal dataset columns for both start_time and end_time",
+            "Use category for the task/status row and series for color grouping",
+            "Set subcategories=true only when series is configured",
+            "Use tooltip_columns for dimensions and tooltip_metrics for aggregates",
+        ],
         "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",
@@ -321,6 +344,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,
+        "gantt_chart_configs": gantt_chart_configs,
         "gauge_configs": gauge_configs,
         "best_practices": best_practices,
         "usage_notes": [
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index b1e50cdbb1b..b910e79b1e3 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -25,7 +25,8 @@ import difflib
 import logging
 import math
 import re
-from datetime import datetime
+from collections.abc import Mapping
+from datetime import datetime, time
 from typing import Annotated, Any, Dict, get_args, List, Literal, Protocol
 
 from pydantic import (
@@ -982,6 +983,35 @@ class SortByConfig(UnknownFieldCheckMixin):
     )
 
 
+class GanttSortByConfig(UnknownFieldCheckMixin):
+    """One physical-column ordering applied before Gantt rendering."""
+
+    model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+    column: str = Field(
+        ...,
+        min_length=1,
+        max_length=255,
+        validation_alias=AliasChoices("column", "col"),
+        description="Physical dataset column used to order Gantt tasks",
+    )
+    ascending: bool = Field(
+        True,
+        description="Sort this column ascending when true, descending when false",
+    )
+
+    @field_validator("column")
+    @classmethod
+    def sanitize_column(cls, value: str) -> str:
+        """Sanitize the order column like every other dimension name."""
+        return sanitize_user_input(
+            value,
+            "Gantt order column",
+            max_length=255,
+            check_sql_keywords=True,
+        )  # type: ignore[return-value]
+
+
 # Actual chart types
 class PieChartConfig(BaseChartConfig):
     model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2207,6 +2237,20 @@ def _reject_sql_expression_on_dimension(col: ColumnRef | None, position: str) ->
         )
 
 
+def _validate_distinct_gantt_roles(roles: Mapping[str, str | None]) -> None:
+    """Require Gantt query-result roles to have distinct canonical labels."""
+    seen: dict[str, str] = {}
+    for role, name in roles.items():
+        if name is None:
+            continue
+        canonical_name = name.casefold()
+        if previous_role := seen.get(canonical_name):
+            raise ValueError(
+                f"{previous_role} and {role} must reference different columns"
+            )
+        seen[canonical_name] = role
+
+
 def _metric_display_label(col: ColumnRef) -> str:
     """Return the display label for a metric column reference."""
     if col.sql_expression:
@@ -2661,6 +2705,757 @@ class WaterfallChartConfig(BaseChartConfig):
         return self
 
 
+class GanttChartConfig(BaseChartConfig):
+    """Typed contract for the ECharts Gantt visualization.
+
+    The field names intentionally describe Gantt semantics while validation
+    aliases accept the native keys used by ``Gantt/buildQuery.ts`` and saved
+    Explore ``form_data``.
+    """
+
+    model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+    chart_type: Literal["gantt"] = "gantt"
+    start_time: ColumnRef = Field(
+        ...,
+        description="Temporal column containing each task's start timestamp",
+        validation_alias=AliasChoices("start_time", "startTime", "start"),
+    )
+    end_time: ColumnRef = Field(
+        ...,
+        description="Temporal column containing each task's end timestamp",
+        validation_alias=AliasChoices("end_time", "endTime", "end"),
+    )
+    category: ColumnRef = Field(
+        ...,
+        description=(
+            "Task/category dimension shown as the Gantt row label (native y_axis)"
+        ),
+        validation_alias=AliasChoices("category", "task", "y_axis", "yAxis"),
+    )
+    series: ColumnRef | None = Field(
+        None,
+        description="Optional dimension used for color series and legend entries",
+    )
+    subcategories: bool = Field(
+        False,
+        description=(
+            "Split each category into separate rows for series values; requires series"
+        ),
+    )
+    tooltip_columns: List[ColumnRef] = Field(
+        default_factory=list,
+        description="Additional physical columns included in task tooltips",
+        max_length=50,
+    )
+    tooltip_metrics: List[ColumnRef] = Field(
+        default_factory=list,
+        description=("Additional aggregate or saved metrics included in task tooltips"),
+        max_length=50,
+    )
+    order_by: List[GanttSortByConfig] = Field(
+        default_factory=list,
+        description=(
+            "Stable task ordering. Native order_by_cols JSON pairs are also "
+            "accepted for saved-form-data round trips."
+        ),
+        validation_alias=AliasChoices("order_by", "order_by_cols"),
+        max_length=100,
+    )
+    filters: List[FilterConfig] | None = Field(
+        None,
+        description=(
+            "Structured filters (column/op/value). Native SIMPLE adhoc_filters "
+            "are adapted; free-form SQL filters are rejected."
+        ),
+    )
+    time_range: str | None = Field(
+        None,
+        max_length=1000,
+        description=(
+            "Optional bounded Superset time range applied to the chart, such as "
+            "'Last 30 days' or '2025-01-01 : 2025-12-31'"
+        ),
+    )
+    row_limit: int = Field(
+        10000,
+        ge=1,
+        le=50000,
+        description="Maximum number of task rows returned",
+    )
+
+    # Presentation controls read by Gantt/transformProps.ts.
+    color_scheme: str | None = Field(None, max_length=100)
+    show_legend: bool = True
+    legend_orientation: LEGEND_POSITION_LITERAL = Field(
+        "top",
+        validation_alias=AliasChoices("legend_orientation", "legendOrientation"),
+    )
+    legend_type: Literal["plain", "scroll"] = Field(
+        "scroll",
+        validation_alias=AliasChoices("legend_type", "legendType"),
+    )
+    legend_margin: int | None = Field(
+        None,
+        ge=0,
+        le=1000,
+        validation_alias=AliasChoices("legend_margin", "legendMargin"),
+    )
+    legend_sort: Literal["asc", "desc"] | None = Field(
+        None,
+        validation_alias=AliasChoices("legend_sort", "legendSort"),
+    )
+    zoomable: bool = False
+    show_extra_controls: bool = False
+    x_axis_time_bounds: tuple[str | None, str | None] | None = Field(
+        None,
+        description="Optional daily HH:MM:SS lower/upper bounds for the time axis",
+    )
+    x_axis_time_format: str = Field("smart_date", max_length=100)
+    tooltip_time_format: str = Field(
+        "smart_date",
+        max_length=100,
+        validation_alias=AliasChoices("tooltip_time_format", "tooltipTimeFormat"),
+    )
+    tooltip_values_format: str = Field(
+        "SMART_NUMBER",
+        max_length=100,
+        validation_alias=AliasChoices("tooltip_values_format", "tooltipValuesFormat"),
+    )
+    x_axis_title: str | None = Field(None, max_length=200)
+    x_axis_title_margin: int | None = Field(None, ge=0, le=1000)
+    y_axis_title: str | None = Field(None, max_length=200)
+    y_axis_title_margin: int | None = Field(None, ge=0, le=1000)
+
+    @model_validator(mode="before")
+    @classmethod
+    def adapt_native_form_data(cls, raw: Any) -> Any:  # noqa: C901
+        """Adapt bounded, recognized native Gantt form data without typo drops."""
+        if not isinstance(raw, dict):
+            return raw
+        data = dict(raw)
+
+        if "y_axis_title_position" in data:
+            raise ValueError(
+                "y_axis_title_position is unsupported by Gantt; the frontend "
+                "always centers the Y-axis title"
+            )
+
+        # Request normalization maps viz_type before this validator, while direct
+        # model validation may still carry the native discriminator.
+        if data.get("viz_type") == "gantt_chart":
+            data.setdefault("chart_type", "gantt")
+            data.pop("viz_type", None)
+        if data.get("chart_type") == "gantt_chart":
+            data["chart_type"] = "gantt"
+
+        # Saved chart transport metadata is not visualization configuration.
+        # Keep this allowlist deliberately narrow so misspelled controls still fail.
+        for key in (
+            "annotation_layers",
+            "dashboards",
+            "datasource",
+            "extra_form_data",
+            "slice_id",
+            "slice_name",
+        ):
+            data.pop(key, None)
+
+        marker_key = "_mcp_dashboard_time_filter_subject"
+        marker_present = marker_key in data
+        marker = data.pop(marker_key, None)
+        if marker_present and (not isinstance(marker, str) or not marker):
+            raise ValueError(f"{marker_key} must be a non-empty physical column name")
+
+        for key in (
+            "start_time",
+            "startTime",
+            "start",
+            "end_time",
+            "endTime",
+            "end",
+            "category",
+            "task",
+            "y_axis",
+            "yAxis",
+            "series",
+        ):
+            if isinstance(data.get(key), str):
+                data[key] = {"name": data[key]}
+
+        for key in ("tooltip_columns",):
+            if key in data and isinstance(data[key], list):
+                data[key] = [
+                    {"name": item} if isinstance(item, str) else item
+                    for item in data[key]
+                ]
+        if isinstance(data.get("tooltip_metrics"), list):
+            data["tooltip_metrics"] = [
+                cls._adapt_native_tooltip_metric(item, index)
+                for index, item in enumerate(data["tooltip_metrics"])
+            ]
+
+        if "adhoc_filters" in data:
+            if "filters" in data:
+                raise ValueError(
+                    "Use either filters or native adhoc_filters for Gantt, not both"
+                )
+            native_filters = data.pop("adhoc_filters")
+            if not isinstance(native_filters, list):
+                raise ValueError("adhoc_filters must be an array")
+            if len(native_filters) > 100:
+                raise ValueError("adhoc_filters accepts at most 100 entries")
+            filters: list[dict[str, Any]] = []
+            temporal_filters: list[tuple[int, str, Any]] = []
+            allowed_filter_keys = {
+                "clause",
+                "comparator",
+                "datasourceWarning",
+                "expressionType",
+                "filterOptionName",
+                "isExtra",
+                "isNew",
+                "operator",
+                "operatorId",
+                "sqlExpression",
+                "subject",
+            }
+            for index, native_filter in enumerate(native_filters):
+                if not isinstance(native_filter, dict):
+                    raise ValueError(f"adhoc_filters[{index}] must be an object")
+                if native_filter.get("expressionType") != "SIMPLE":
+                    raise ValueError(
+                        f"adhoc_filters[{index}] must use expressionType='SIMPLE'; "
+                        "free-form SQL filters are not supported by typed Gantt"
+                    )
+                unknown_filter_keys = set(native_filter) - allowed_filter_keys
+                if unknown_filter_keys:
+                    raise ValueError(
+                        f"adhoc_filters[{index}] has unsupported fields: "
+                        f"{', '.join(sorted(unknown_filter_keys))}"
+                    )
+                if native_filter.get("clause") not in (None, "WHERE"):
+                    raise ValueError(f"adhoc_filters[{index}] must use clause='WHERE'")
+                for metadata_key in ("isExtra", "isNew", "datasourceWarning"):
+                    metadata_value = native_filter.get(metadata_key)
+                    if metadata_value is not None and not isinstance(
+                        metadata_value, bool
+                    ):
+                        raise ValueError(
+                            f"adhoc_filters[{index}].{metadata_key} must be boolean"
+                        )
+                for metadata_key, max_length in (
+                    ("filterOptionName", 500),
+                    ("operatorId", 100),
+                ):
+                    metadata_value = native_filter.get(metadata_key)
+                    if metadata_value is not None and (
+                        not isinstance(metadata_value, str)
+                        or not metadata_value
+                        or len(metadata_value) > max_length
+                    ):
+                        raise ValueError(
+                            f"adhoc_filters[{index}].{metadata_key} must be a "
+                            "non-empty string"
+                        )
+                if native_filter.get("sqlExpression") is not None:
+                    raise ValueError(
+                        f"adhoc_filters[{index}].sqlExpression must be null for "
+                        "expressionType='SIMPLE'"
+                    )
+                subject = native_filter.get("subject")
+                operator = native_filter.get("operator")
+                operator_id = native_filter.get("operatorId")
+                comparator = native_filter.get("comparator")
+                if not isinstance(operator, str) or not operator:
+                    raise ValueError(
+                        f"adhoc_filters[{index}].operator must be a non-empty string"
+                    )
+                operator_id_map: dict[str, str | None] = {
+                    "EQUALS": "==",
+                    "NOT_EQUALS": "!=",
+                    "LESS_THAN": "<",
+                    "LESS_THAN_OR_EQUAL": "<=",
+                    "GREATER_THAN": ">",
+                    "GREATER_THAN_OR_EQUAL": ">=",
+                    "IN": "IN",
+                    "NOT_IN": "NOT IN",
+                    "LIKE": "LIKE",
+                    "ILIKE": "ILIKE",
+                    "IS_NOT_NULL": "IS NOT NULL",
+                    "IS_NULL": "IS NULL",
+                    "TEMPORAL_RANGE": "TEMPORAL_RANGE",
+                    # These are real Explore operator IDs, but FilterConfig and
+                    # the shared query-filter contract cannot represent them
+                    # without changing their meaning. Reject them explicitly.
+                    "LATEST_PARTITION": None,
+                    "IS_TRUE": None,
+                    "IS_FALSE": None,
+                    "CONTAINS_ANY": None,
+                    "CONTAINS_ALL": None,
+                    "IS_EMPTY": None,
+                    "IS_NOT_EMPTY": None,
+                    "LENGTH_EQUALS": None,
+                    "LENGTH_GREATER_THAN": None,
+                    "LENGTH_LESS_THAN": None,
+                    "LENGTH_GREATER_THAN_OR_EQUALS": None,
+                    "LENGTH_LESS_THAN_OR_EQUALS": None,
+                }
+                if operator_id is not None:
+                    if operator_id not in operator_id_map:
+                        raise ValueError(
+                            f"adhoc_filters[{index}].operatorId {operator_id!r} "
+                            "is not a recognized Explore operator ID"
+                        )
+                    expected_operator = operator_id_map[operator_id]
+                    if expected_operator is None:
+                        raise ValueError(
+                            f"adhoc_filters[{index}].operatorId {operator_id!r} "
+                            "is not supported by typed Gantt filters"
+                        )
+                    if operator != expected_operator:
+                        raise ValueError(
+                            f"adhoc_filters[{index}] has contradictory operator "
+                            f"{operator!r} for operatorId {operator_id!r}; expected "
+                            f"{expected_operator!r}"
+                        )
+
+                supported_operators = {
+                    expected
+                    for expected in operator_id_map.values()
+                    if expected is not None
+                } | {"NOT LIKE"}
+                if operator not in supported_operators:
+                    raise ValueError(
+                        f"adhoc_filters[{index}].operator {operator!r} is not "
+                        "supported by typed Gantt filters"
+                    )
+                if operator == "TEMPORAL_RANGE":
+                    if not isinstance(subject, str) or not subject:
+                        raise ValueError(
+                            f"adhoc_filters[{index}] temporal filter needs subject"
+                        )
+                    if not isinstance(comparator, str) or not comparator:
+                        raise ValueError(
+                            f"adhoc_filters[{index}] temporal comparator must "
+                            "be a non-empty string"
+                        )
+                    temporal_filters.append((index, subject, comparator))
+                    continue
+                if not isinstance(subject, str) or not subject:
+                    raise ValueError(f"adhoc_filters[{index}] needs subject")
+                if operator in {"IS NULL", "IS NOT NULL"}:
+                    if comparator is not None:
+                        raise ValueError(
+                            f"adhoc_filters[{index}] operator {operator!r} must not "
+                            "define comparator"
+                        )
+                elif operator in {"IN", "NOT IN"}:
+                    if not isinstance(comparator, list) or not comparator:
+                        raise ValueError(
+                            f"adhoc_filters[{index}] operator {operator!r} requires "
+                            "a non-empty comparator array"
+                        )
+                elif comparator is None or isinstance(comparator, (dict, list)):
+                    raise ValueError(
+                        f"adhoc_filters[{index}] operator {operator!r} requires "
+                        "a scalar comparator"
+                    )
+                filters.append(
+                    {
+                        "column": subject,
+                        "op": "=" if operator == "==" else operator,
+                        "value": comparator,
+                    }
+                )
+            data["filters"] = filters or None
+
+            if len(temporal_filters) > 1:
+                indexes = ", ".join(str(item[0]) for item in temporal_filters)
+                raise ValueError(
+                    "Typed Gantt supports at most one TEMPORAL_RANGE adhoc filter; "
+                    f"found filters at indexes {indexes}"
+                )
+            if temporal_filters:
+                index, subject, comparator = temporal_filters[0]
+                configured_subject = data.get("temporal_column")
+                if configured_subject is not None and configured_subject != subject:
+                    raise ValueError(
+                        f"temporal_column conflicts with adhoc_filters[{index}].subject"
+                    )
+                data["temporal_column"] = subject
+                if marker_present and marker != subject:
+                    raise ValueError(
+                        f"{marker_key} conflicts with adhoc_filters[{index}].subject"
+                    )
+                if comparator not in (None, "", "No filter"):
+                    configured_range = data.get("time_range")
+                    if configured_range is not None and configured_range != comparator:
+                        raise ValueError(
+                            "time_range conflicts with "
+                            f"adhoc_filters[{index}].comparator"
+                        )
+                    data["time_range"] = comparator
+            elif marker_present:
+                raise ValueError(
+                    f"{marker_key} requires a matching TEMPORAL_RANGE adhoc filter"
+                )
+        elif marker_present:
+            raise ValueError(
+                f"{marker_key} requires a matching TEMPORAL_RANGE adhoc filter"
+            )
+        return data
+
+    @staticmethod
+    def _adapt_native_tooltip_metric(metric: Any, index: int) -> Any:  # noqa: C901
+        """Convert one bounded native QueryFormMetric into a typed metric ref."""
+        if isinstance(metric, str):
+            return {"name": metric, "saved_metric": True}
+        if not isinstance(metric, dict):
+            raise ValueError(
+                f"tooltip_metrics[{index}] must be a saved metric name or object"
+            )
+        # Typed ColumnRef objects do not carry the frontend discriminator and
+        # must continue through normal Pydantic validation unchanged.
+        if "expressionType" not in metric:
+            return metric
+
+        allowed_keys = {
+            "aggregate",
+            "column",
+            "datasourceWarning",
+            "expressionType",
+            "hasCustomLabel",
+            "isNew",
+            "label",
+            "optionName",
+            "sqlExpression",
+        }
+        if unknown := set(metric) - allowed_keys:
+            raise ValueError(
+                f"tooltip_metrics[{index}] has unsupported fields: "
+                f"{', '.join(sorted(unknown))}"
+            )
+        option_name = metric.get("optionName")
+        if option_name is not None and (
+            not isinstance(option_name, str)
+            or not option_name
+            or len(option_name) > 500
+        ):
+            raise ValueError(
+                f"tooltip_metrics[{index}].optionName must be a non-empty string"
+            )
+        datasource_warning = metric.get("datasourceWarning")
+        if datasource_warning is not None and not isinstance(datasource_warning, bool):
+            raise ValueError(
+                f"tooltip_metrics[{index}].datasourceWarning must be boolean"
+            )
+        custom_label = metric.get("hasCustomLabel")
+        if custom_label is not None and not isinstance(custom_label, bool):
+            raise ValueError(f"tooltip_metrics[{index}].hasCustomLabel must be boolean")
+        is_new = metric.get("isNew")
+        if is_new is not None and not isinstance(is_new, bool):
+            raise ValueError(f"tooltip_metrics[{index}].isNew must be boolean")
+
+        expression_type = metric.get("expressionType")
+        label = metric.get("label")
+        if label is not None and (not isinstance(label, str) or not label):
+            raise ValueError(
+                f"tooltip_metrics[{index}].label must be a non-empty string"
+            )
+        if expression_type == "SIMPLE":
+            aggregate = metric.get("aggregate")
+            column = metric.get("column")
+            if not isinstance(aggregate, str) or not aggregate:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] SIMPLE metric needs aggregate"
+                )
+            if not isinstance(column, dict):
+                raise ValueError(f"tooltip_metrics[{index}] SIMPLE metric needs column")
+            # QueryFormMetric embeds the selected frontend Column metadata.
+            # Keep this allowlist aligned with the frontend Column contract and
+            # the additional fields emitted by TableColumn.data.
+            allowed_column_keys = {
+                "advanced_data_type",
+                "certification_details",
+                "certified_by",
+                "columnName",
+                "column_name",
+                "database_expression",
+                "description",
+                "expression",
+                "filterBy",
+                "filterable",
+                "groupby",
+                "id",
+                "is_certified",
+                "is_dttm",
+                "optionName",
+                "python_date_format",
+                "type",
+                "type_generic",
+                "uuid",
+                "value",
+                "verbose_name",
+                "warning_markdown",
+            }
+            unknown_column_keys = set(column) - allowed_column_keys
+            if unknown_column_keys:
+                raise ValueError(
+                    f"tooltip_metrics[{index}].column has unsupported fields: "
+                    f"{', '.join(sorted(unknown_column_keys))}"
+                )
+            boolean_fields = {"filterable", "groupby", "is_certified", "is_dttm"}
+            for metadata_key in boolean_fields & column.keys():
+                if not isinstance(column[metadata_key], bool):
+                    raise ValueError(
+                        f"tooltip_metrics[{index}].column.{metadata_key} "
+                        "must be boolean"
+                    )
+            if "id" in column and (
+                isinstance(column["id"], bool)
+                or not isinstance(column["id"], int)
+                or column["id"] < 0
+            ):
+                raise ValueError(
+                    f"tooltip_metrics[{index}].column.id must be a non-negative integer"
+                )
+            if "type_generic" in column:
+                type_generic = column["type_generic"]
+                if type_generic is not None and (
+                    isinstance(type_generic, bool)
+                    or not isinstance(type_generic, int)
+                    or type_generic not in range(5)
+                ):
+                    raise ValueError(
+                        f"tooltip_metrics[{index}].column.type_generic must be "
+                        "null or a GenericDataType value from 0 through 4"
+                    )
+
+            nullable_string_fields = {
+                "advanced_data_type",
+                "certification_details",
+                "certified_by",
+                "database_expression",
+                "description",
+                "expression",
+                "python_date_format",
+                "type",
+                "uuid",
+                "verbose_name",
+                "warning_markdown",
+            }
+            for metadata_key in nullable_string_fields & column.keys():
+                metadata_value = column[metadata_key]
+                if metadata_value is not None and (
+                    not isinstance(metadata_value, str) or len(metadata_value) > 2000
+                ):
+                    raise ValueError(
+                        f"tooltip_metrics[{index}].column.{metadata_key} must be "
+                        "null or a string of at most 2000 characters"
+                    )
+
+            string_fields = {
+                "columnName",
+                "column_name",
+                "filterBy",
+                "optionName",
+                "value",
+            }
+            for metadata_key in string_fields & column.keys():
+                metadata_value = column[metadata_key]
+                if (
+                    not isinstance(metadata_value, str)
+                    or not metadata_value
+                    or len(metadata_value) > 2000
+                ):
+                    raise ValueError(
+                        f"tooltip_metrics[{index}].column.{metadata_key} must be "
+                        "a non-empty string of at most 2000 characters"
+                    )
+            column_name = column.get("column_name") or column.get("columnName")
+            if not isinstance(column_name, str) or not column_name:
+                raise ValueError(f"tooltip_metrics[{index}].column needs column_name")
+            if (
+                column.get("column_name") is not None
+                and column.get("columnName") is not None
+                and column["column_name"] != column["columnName"]
+            ):
+                raise ValueError(
+                    f"tooltip_metrics[{index}].column has conflicting column names"
+                )
+            column_type = column.get("type")
+            if isinstance(column_type, str) and len(column_type) > 255:
+                raise ValueError(
+                    f"tooltip_metrics[{index}].column.type must be at most 255 "
+                    "characters"
+                )
+            if metric.get("sqlExpression") is not None:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] SIMPLE metric cannot set sqlExpression"
+                )
+            if custom_label is True and label is None:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] custom label requires label"
+                )
+            default_label = f"{aggregate.upper()}({column_name})"
+            typed_label = label if custom_label or label != default_label else None
+            return {
+                "name": column_name,
+                "aggregate": aggregate,
+                "label": typed_label,
+            }
+        if expression_type == "SQL":
+            sql_expression = metric.get("sqlExpression")
+            if not isinstance(sql_expression, str) or not sql_expression:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] SQL metric needs sqlExpression"
+                )
+            if metric.get("aggregate") is not None or metric.get("column") is not None:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] SQL metric cannot set "
+                    "aggregate or column"
+                )
+            if custom_label is True and label is None:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] custom label requires label"
+                )
+            # ColumnRef requires an output alias for SQL metrics. The frontend's
+            # getMetricLabel uses sqlExpression when no custom label is present,
+            # so using that exact fallback keeps the query-result key stable.
+            return {"sql_expression": sql_expression, "label": label or sql_expression}
+        raise ValueError(
+            f"tooltip_metrics[{index}].expressionType must be 'SIMPLE' or 'SQL'"
+        )
+
+    @field_validator("start_time", "end_time", "category", "series", mode="before")
+    @classmethod
+    def coerce_single_column(cls, value: Any) -> Any:
+        """Accept native bare physical-column names."""
+        return {"name": value} if isinstance(value, str) else value
+
+    @field_validator("tooltip_columns", mode="before")
+    @classmethod
+    def coerce_tooltip_columns(cls, value: Any) -> Any:
+        if not isinstance(value, list):
+            return value
+        return [{"name": item} if isinstance(item, str) else item for item in value]
+
+    @field_validator("order_by", mode="before")
+    @classmethod
+    def parse_native_order_by(cls, value: Any) -> Any:
+        """Parse the frontend's JSON ``[column, ascending]`` strings safely."""
+        if value is None:
+            return []
+        if not isinstance(value, list):
+            raise ValueError("order_by must be an array")
+        if len(value) > 100:
+            raise ValueError("order_by accepts at most 100 entries")
+
+        from superset.utils import json as utils_json
+
+        parsed: list[Any] = []
+        for index, item in enumerate(value):
+            if isinstance(item, str):
+                if len(item) > 1000:
+                    raise ValueError(f"order_by[{index}] is too long")
+                try:
+                    item = utils_json.loads(item)
+                except (TypeError, ValueError) as ex:
+                    raise ValueError(f"order_by[{index}] is not valid JSON") from ex
+            if isinstance(item, (list, tuple)):
+                if (
+                    len(item) != 2
+                    or not isinstance(item[0], str)
+                    or not item[0]
+                    or not isinstance(item[1], bool)
+                ):
+                    raise ValueError(
+                        f"order_by[{index}] must be [column, ascending_boolean]"
+                    )
+                item = {"column": item[0], "ascending": item[1]}
+            parsed.append(item)
+        return parsed
+
+    @field_validator("time_range")
+    @classmethod
+    def validate_gantt_time_range(cls, value: str | None) -> str | None:
+        return validate_time_range(value)
+
+    @field_validator("x_axis_time_bounds")
+    @classmethod
+    def validate_time_bounds(
+        cls, value: tuple[str | None, str | None] | None
+    ) -> tuple[str | None, str | None] | None:
+        if value is None:
+            return None
+        value = (value[0] or None, value[1] or None)
+        for bound in value:
+            if bound is None:
+                continue
+            try:
+                time.fromisoformat(bound)
+            except ValueError as ex:
+                raise ValueError("x_axis_time_bounds values must use HH:MM:SS") from ex
+            if len(bound) != 8:
+                raise ValueError("x_axis_time_bounds values must use HH:MM:SS")
+        return value
+
+    @model_validator(mode="after")
+    def validate_gantt_roles(self) -> "GanttChartConfig":
+        """Keep metric and dimension roles aligned with the frontend controls."""
+        dimension_fields: list[tuple[str, ColumnRef | None]] = [
+            ("start_time", self.start_time),
+            ("end_time", self.end_time),
+            ("category", self.category),
+            ("series", self.series),
+        ]
+        dimension_fields.extend(
+            (f"tooltip_columns[{index}]", column)
+            for index, column in enumerate(self.tooltip_columns)
+        )
+        for field_name, column in dimension_fields:
+            _reject_sql_expression_on_dimension(column, field_name)
+            if column is not None and column.saved_metric:
+                raise ValueError(
+                    f"{field_name} cannot use saved_metric=True; it is a dimension"
+                )
+            if column is not None and column.aggregate:
+                raise ValueError(
+                    f"{field_name} cannot define aggregate; it is a dimension"
+                )
+
+        for index, metric in enumerate(self.tooltip_metrics):
+            if not metric.is_metric:
+                raise ValueError(
+                    f"tooltip_metrics[{index}] must define aggregate, "
+                    "saved_metric=True, or sql_expression"
+                )
+
+        _validate_distinct_gantt_roles(
+            {
+                "start_time": self.start_time.name,
+                "end_time": self.end_time.name,
+                "category": self.category.name,
+            }
+        )
+
+        if self.subcategories and self.series is None:
+            raise ValueError("subcategories=True requires series")
+        if (
+            self.series is not None
+            and self.series.name is not None
+            and self.category.name is not None
+            and self.series.name.casefold() == self.category.name.casefold()
+        ):
+            raise ValueError("series and category must reference different columns")
+
+        order_names = [item.column.casefold() for item in self.order_by]
+        if len(order_names) != len(set(order_names)):
+            raise ValueError("order_by cannot contain duplicate columns")
+        return self
+
+
 # Discriminated union for runtime validation (not exposed in JSON Schema)
 ChartConfig = Annotated[
     XYChartConfig
@@ -2675,14 +3470,15 @@ ChartConfig = Annotated[
     | BigNumberChartConfig
     | HistogramChartConfig
     | BoxPlotChartConfig
-    | WaterfallChartConfig,
+    | WaterfallChartConfig
+    | GanttChartConfig,
     Field(
         discriminator="chart_type",
         description=(
             "Chart configuration - specify chart_type as 'xy', 'table', "
             "'pie', 'gauge', 'treemap_v2', 'pivot_table', 'interactive_pivot', "
             "'mixed_timeseries', 'handlebars', "
-            "'big_number', 'histogram', 'box_plot', or 'waterfall'"
+            "'big_number', 'histogram', 'box_plot', 'waterfall', or 'gantt'"
         ),
     ),
 ]
@@ -2712,6 +3508,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),
+    "gantt_chart": ("gantt", 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 bee231145a1..47bd90f24e1 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -86,9 +86,8 @@ 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', 'treemap_v2', 'pivot_table', 'mixed_timeseries',
-      'handlebars',
-      'big_number', 'histogram', 'box_plot', 'waterfall', plus host-gated
+      'gauge', 'treemap_v2', 'pivot_table', 'mixed_timeseries', 'handlebars',
+      'big_number', 'histogram', 'box_plot', 'waterfall', 'gantt', plus host-gated
       types returned by get_chart_type_schema such as 'interactive_pivot')
 
     IMPORTANT: The 'chart_type' field in the config is a DISCRIMINATOR that determines
@@ -144,6 +143,11 @@ async def generate_chart(  # noqa: C901
       Required fields: x_axis, metric; optional: breakdown (single category
       column, alias: groupby), show_total
 
+    - Use chart_type='gantt' for task intervals over time.
+      Required fields: start_time, end_time (both temporal), category;
+      optional: series, tooltip_columns, tooltip_metrics, order_by, filters,
+      time_range, subcategories, and presentation controls
+
     Quick lookup — natural-language ask -> chart_type (+ kind if applicable):
     - "bar chart" / "line chart" / "area chart" / "scatter plot"
       -> chart_type='xy', kind='bar'/'line'/'area'/'scatter'
@@ -159,6 +163,7 @@ async def generate_chart(  # noqa: C901
     - "custom HTML template" -> chart_type='handlebars'
     - "histogram" / "distribution" -> chart_type='histogram'
     - "box plot" / "box and whisker" -> chart_type='box_plot'
+    - "gantt" / "project schedule" / "task timeline" -> chart_type='gantt'
 
     Example usage for XY chart (bar/line/area/scatter):
     ```json
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index 8448a79b9f7..cfe190fda2b 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -125,6 +125,7 @@ _VIZ_CATEGORY: dict[str, str] = {
     # Own category: cumulative-flow semantics differ from a plain bar, like
     # funnel/gauge carry distinct categories.
     "waterfall": "waterfall",
+    "gantt_chart": "gantt",
 }
 
 _MAX_RECOMMENDATIONS = 4
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 9ff3e89c653..f40d9a3bc2b 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -40,6 +40,7 @@ from superset.mcp_service.chart.chart_helpers import (
 )
 from superset.mcp_service.chart.chart_utils import validate_chart_dataset
 from superset.mcp_service.chart.preview_utils import (
+    _generate_gantt_vega_lite_preview,
     generate_gauge_ascii_preview,
     generate_gauge_vega_lite_preview,
 )
@@ -405,6 +406,25 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
         except (ValueError, TypeError):
             return None
 
+    def _create_gantt_preview(
+        self, data: Any, form_data: Dict[str, Any]
+    ) -> VegaLitePreview | ChartError:
+        """Build the saved-chart wrapper around the shared Gantt preview."""
+        preview = _generate_gantt_vega_lite_preview(data, form_data)
+        if isinstance(preview, ChartError):
+            return preview
+        preview.specification.update(
+            {
+                "description": (
+                    "Chart preview for "
+                    f"{getattr(self.chart, 'slice_name', 'Untitled Chart')}"
+                ),
+                "width": self.request.width or 400,
+                "height": self.request.height or 300,
+            }
+        )
+        return preview
+
     def generate(self) -> VegaLitePreview | ChartError:  # noqa: C901
         """Generate Vega-Lite JSON specification from chart data."""
         try:
@@ -472,7 +492,20 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
 
             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):
+            viz_type = getattr(self.chart, "viz_type", None) or form_data.get(
+                "viz_type"
+            )
+            if viz_type == "gantt_chart":
+                return self._create_gantt_preview(chart_data, form_data)
+            if not isinstance(chart_data, list):
+                return ChartError(
+                    error="Chart result data is not an array of rows",
+                    error_type="InvalidResultData",
+                )
+            # An empty Gantt query is still a valid interval chart and has a
+            # useful, typed Vega-Lite spec. Other chart types retain the existing
+            # explicit no-data response.
+            if not chart_data and viz_type != "gantt_chart":
                 return ChartError(
                     error="No data available for Vega-Lite visualization",
                     error_type="NoDataError",
@@ -505,8 +538,17 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
 
     def _create_vega_lite_spec(self, data: List[Any]) -> Dict[str, Any]:
         """Create Vega-Lite specification from chart data."""
-        if not data:
-            return {"data": {"values": []}, "mark": "point"}
+        form_data = self._get_form_data() or {}
+        viz_type = (
+            getattr(self.chart, "viz_type", None)
+            or form_data.get("viz_type")
+            or "table"
+        )
+        if viz_type == "gantt_chart":
+            preview = self._create_gantt_preview(data, form_data)
+            if isinstance(preview, ChartError):
+                raise ValueError(preview.error)
+            return preview.specification
 
         # Get data fields and analyze types
         first_row = data[0] if data else {}
@@ -514,8 +556,6 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
         field_types = self._analyze_field_types(data, fields)
 
         # Determine chart type based on Superset viz_type
-        viz_type = getattr(self.chart, "viz_type", "table") or "table"
-
         # Basic Vega-Lite specification
         spec = {
             "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
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 19f40392545..4b4c00aae75 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -32,6 +32,7 @@ from superset.extensions import event_logger
 from superset.mcp_service.chart.schemas import (
     BigNumberChartConfig,
     BoxPlotChartConfig,
+    GanttChartConfig,
     GaugeChartConfig,
     HandlebarsChartConfig,
     HistogramChartConfig,
@@ -73,6 +74,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
     "histogram": TypeAdapter(HistogramChartConfig),
     "box_plot": TypeAdapter(BoxPlotChartConfig),
     "waterfall": TypeAdapter(WaterfallChartConfig),
+    "gantt": TypeAdapter(GanttChartConfig),
 }
 
 VALID_CHART_TYPES = sorted(_CHART_TYPE_ADAPTERS.keys())
@@ -220,6 +222,17 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
             "show_total": True,
         },
     ],
+    "gantt": [
+        {
+            "chart_type": "gantt",
+            "start_time": {"name": "start_time"},
+            "end_time": {"name": "end_time"},
+            "category": {"name": "task_name"},
+            "series": {"name": "owner"},
+            "tooltip_columns": [{"name": "project"}],
+            "order_by": [{"column": "start_time", "ascending": True}],
+        },
+    ],
     "gauge": [
         {
             "chart_type": "gauge",
@@ -323,7 +336,7 @@ def get_chart_type_schema(
 
     Valid chart_type values depend on the host deployment. Core types are xy,
     table, pie, gauge, treemap_v2, pivot_table, mixed_timeseries, handlebars,
-    big_number, histogram, box_plot, and waterfall. Deployments that enable an
+    big_number, histogram, box_plot, waterfall, and gantt. 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 b011895f120..43d08f99a0d 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -43,18 +43,23 @@ from superset.mcp_service.chart.chart_utils import (
     merge_chart_form_data,
     merge_interactive_pivot_ui_config,
     merge_table_column_config,
+    validate_gantt_form_data,
 )
 from superset.mcp_service.chart.compile import validate_and_compile
 from superset.mcp_service.chart.schemas import (
     AccessibilityMetadata,
     ChartConfig,
     ColumnRef,
+    GanttChartConfig,
     GaugeChartConfig,
     GenerateChartResponse,
     PerformanceMetadata,
     TableChartConfig,
     UpdateChartRequest,
 )
+from superset.mcp_service.chart.validation.dataset_validator import (
+    GanttSemanticNormalizationError,
+)
 from superset.mcp_service.utils.oauth2_utils import (
     build_oauth2_redirect_message,
     OAUTH2_CONFIG_ERROR_MESSAGE,
@@ -370,11 +375,11 @@ def _build_replacement_form_data(
     new_form_data.pop("_mcp_warnings", None)
     dataset_rebind = replacement_dataset_id is not None
     if replacement_dataset_id is not None and not isinstance(
-        parsed_config, GaugeChartConfig
+        parsed_config, (GanttChartConfig, GaugeChartConfig)
     ):
         # Drop only the inherited state the replacement dataset cannot
-        # resolve, then merge as a same-dataset update. Gauge keeps the
-        # stricter presentation-only rebind handled downstream.
+        # resolve, then merge as a same-dataset update. Gantt and Gauge keep the
+        # stricter rebind contracts handled downstream.
         invalid_keys = _inherited_state_invalid_keys(
             existing_form_data,
             new_form_data,
@@ -580,6 +585,22 @@ def _validate_update_against_dataset(
             }
         )
 
+    try:
+        merged_gantt_config = validate_gantt_form_data(
+            form_data,
+            dataset.id,
+        )
+    except GanttSemanticNormalizationError as ex:
+        return _validation_error_response(
+            message="Gantt chart column roles are invalid",
+            details=str(ex),
+        )
+    if merged_gantt_config is not None:
+        # Validation must describe the state that will actually be queried or
+        # persisted, including any omitted series/subcategory values restored
+        # from the saved chart.
+        parsed_config = merged_gantt_config
+
     compile_result = validate_and_compile(
         parsed_config, form_data, dataset, run_compile_check=run_compile_check
     )
@@ -869,6 +890,11 @@ async def update_chart(  # noqa: C901
                     request = request.model_copy(
                         update={"add_columns": validation_config.columns}
                     )
+            except GanttSemanticNormalizationError as ex:
+                return _validation_error_response(
+                    message="Gantt chart column roles are invalid",
+                    details=str(ex),
+                )
             except NORMALIZATION_EXCEPTIONS as e:
                 logger.warning(
                     "Column normalization failed for chart %s: %s", chart.id, e
@@ -1071,6 +1097,11 @@ async def update_chart(  # noqa: C901
         }
         return GenerateChartResponse.model_validate(result)
 
+    except GanttSemanticNormalizationError as ex:
+        return _validation_error_response(
+            message="Gantt chart column roles are invalid",
+            details=str(ex),
+        )
     except OAuth2RedirectError as ex:
         await ctx.warning(
             "Chart update requires OAuth authentication: identifier=%s"
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 1c52d1fdb9f..3b50eb95e4b 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -41,6 +41,7 @@ from superset.mcp_service.chart.chart_utils import (
     merge_chart_form_data,
     merge_interactive_pivot_ui_config,
     merge_table_column_config,
+    validate_gantt_form_data,
 )
 from superset.mcp_service.chart.compile import validate_and_compile
 from superset.mcp_service.chart.preview_utils import (
@@ -54,6 +55,9 @@ from superset.mcp_service.chart.schemas import (
     UpdateChartPreviewRequest,
     UpdateChartPreviewResponse,
 )
+from superset.mcp_service.chart.validation.dataset_validator import (
+    GanttSemanticNormalizationError,
+)
 from superset.mcp_service.utils.oauth2_utils import (
     build_oauth2_redirect_message,
     OAUTH2_CONFIG_ERROR_MESSAGE,
@@ -222,6 +226,20 @@ def update_chart_preview(  # noqa: C901
                     dataset_rebind=dataset_rebind,
                 )
 
+            merged_gantt_config = validate_gantt_form_data(
+                new_form_data,
+                request.dataset_id,
+                dataset_context=(
+                    build_dataset_context_from_orm(dataset)
+                    if new_form_data.get("viz_type") == "gantt_chart"
+                    else None
+                ),
+            )
+            if merged_gantt_config is not None:
+                # Compile the final cached state rather than the pre-merge
+                # request, so preserved native fields cannot bypass semantics.
+                config = merged_gantt_config
+
             # Tier-1 schema validation against the dataset (no DB roundtrip).
             # Runs AFTER the filter merge so filter columns are also validated.
             from superset.daos.dataset import DatasetDAO
@@ -409,6 +427,30 @@ def update_chart_preview(  # noqa: C901
             "schema_version": "2.0",
             "api_version": "v1",
         }
+    except GanttSemanticNormalizationError as ex:
+        execution_time = int((time.time() - start_time) * 1000)
+        return {
+            "chart": None,
+            "error": {
+                "error_type": "gantt_semantic_validation_error",
+                "message": "Gantt chart column roles are invalid",
+                "details": str(ex),
+                "suggestions": [
+                    "Use different physical columns for start_time and end_time",
+                    "Use different physical columns for category and series",
+                    "Use exact dataset column casing when names differ only by case",
+                ],
+                "error_code": "GANTT_SEMANTIC_VALIDATION_ERROR",
+            },
+            "performance": {
+                "query_duration_ms": execution_time,
+                "cache_status": "error",
+                "optimization_suggestions": [],
+            },
+            "success": False,
+            "schema_version": "2.0",
+            "api_version": "v1",
+        }
     except (
         SupersetException,
         CommandException,
diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py
index 7747dca4c0d..d7cb7158b56 100644
--- a/superset/mcp_service/chart/validation/dataset_validator.py
+++ b/superset/mcp_service/chart/validation/dataset_validator.py
@@ -46,6 +46,10 @@ _NUMERIC_TYPE_PATTERN = re.compile(
 )
 
 
+class GanttSemanticNormalizationError(ValueError):
+    """A Gantt canonicalization result violates its typed semantic contract."""
+
+
 class AmbiguousDatasetReferenceError(ValueError):
     """A non-exact reference matches multiple names that differ only by case."""
 
@@ -64,7 +68,12 @@ class AmbiguousDatasetReferenceError(ValueError):
 def resolve_dataset_reference(
     name: str, candidates: Iterable[str], reference_kind: str
 ) -> str | None:
-    """Resolve exact names first and reject ambiguous case-insensitive matches."""
+    """Resolve a dataset reference deterministically.
+
+    Exact spelling wins regardless of metadata order. A single case-insensitive
+    match is canonicalized, while multiple case-insensitive matches require the
+    caller to provide exact casing.
+    """
     candidate_names = list(candidates)
     if name in candidate_names:
         return name
@@ -336,8 +345,9 @@ class DatasetValidator:
                     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.
+                # The ref did not opt into saved-metric lookup, so an ambiguous
+                # metric-only spelling is still best explained by the tailored
+                # saved_metric hint below.
                 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
@@ -375,7 +385,7 @@ class DatasetValidator:
     def _build_ambiguous_reference_error(
         error: AmbiguousDatasetReferenceError,
     ) -> ChartGenerationError:
-        """Build an actionable error for case-colliding dataset metadata."""
+        """Build an actionable validation error for case-colliding metadata."""
         return ChartGenerationError(
             error_type="ambiguous_dataset_reference",
             message=(
@@ -478,7 +488,7 @@ class DatasetValidator:
 
     @staticmethod
     def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
-        """Check if a physical column or saved metric resolves unambiguously."""
+        """Check if a column or metric resolves without ambiguity."""
         for candidates, reference_kind in (
             (dataset_context.available_columns, "physical column"),
             (dataset_context.available_metrics, "saved metric"),
@@ -501,9 +511,10 @@ class DatasetValidator:
         """
         Get the canonical column name from the dataset.
 
-        Performs case-insensitive matching and returns the actual column name
-        as stored in the dataset. This ensures column names in form_data match
-        exactly with what the frontend expects.
+        Exact spelling wins; a unique case-insensitive match returns the actual
+        physical column name as stored in the dataset. For backward-compatible
+        generic callers, saved metrics are a fallback when no column matches.
+        Ambiguous spellings fail within either namespace.
 
         Args:
             column_name: The column name to normalize
diff --git a/superset/mcp_service/chart/validation/pipeline.py b/superset/mcp_service/chart/validation/pipeline.py
index 62badb5e744..c42737fdf67 100644
--- a/superset/mcp_service/chart/validation/pipeline.py
+++ b/superset/mcp_service/chart/validation/pipeline.py
@@ -133,9 +133,30 @@ class ValidationPipeline:
             # Runtime validation always returns True now, warnings are informational
 
             # Layer 4: Column name normalization (reuses context)
-            normalized_request = ValidationPipeline._normalize_column_names(
-                request, dataset_context, typed_config=typed_config
-            )
+            from .dataset_validator import GanttSemanticNormalizationError
+
+            try:
+                normalized_request = ValidationPipeline._normalize_column_names(
+                    request, dataset_context, typed_config=typed_config
+                )
+            except GanttSemanticNormalizationError as ex:
+                return ValidationResult(
+                    is_valid=False,
+                    request=request,
+                    error=ChartGenerationError(
+                        error_type="gantt_semantic_validation_error",
+                        message="Gantt chart column roles are invalid",
+                        details=str(ex),
+                        suggestions=[
+                            "Use different physical columns for start_time and "
+                            "end_time",
+                            "Use different physical columns for category and series",
+                            "Use exact dataset column casing when names differ only "
+                            "by case",
+                        ],
+                        error_code="GANTT_SEMANTIC_VALIDATION_ERROR",
+                    ),
+                )
 
             return ValidationResult(
                 is_valid=True,
@@ -250,7 +271,10 @@ class ValidationPipeline:
             A new request with normalized column names
         """
         try:
-            from .dataset_validator import DatasetValidator
+            from .dataset_validator import (
+                DatasetValidator,
+                GanttSemanticNormalizationError,
+            )
 
             config = typed_config or request.config
             normalized_config = DatasetValidator.normalize_column_names(
@@ -265,6 +289,8 @@ class ValidationPipeline:
 
             return GenerateChartRequest.model_validate(request_dict)
 
+        except GanttSemanticNormalizationError:
+            raise
         except (ImportError, AttributeError, KeyError, ValueError, TypeError) as e:
             # If normalization fails, return the original request
             # Validation has already passed, so this is a non-critical failure
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_examples.py b/tests/unit_tests/mcp_service/chart/test_chart_examples.py
new file mode 100644
index 00000000000..a6d5c33125d
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_chart_examples.py
@@ -0,0 +1,59 @@
+# 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.
+
+"""Ensure published MCP chart examples remain parseable."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from pydantic import TypeAdapter
+
+from superset.mcp_service.chart.schemas import ChartConfig
+from superset.mcp_service.chart.tool.get_chart_type_schema import _CHART_EXAMPLES
+from superset.utils import json
+
+
+@pytest.mark.parametrize(
+    "example",
+    [example for examples in _CHART_EXAMPLES.values() for example in examples],
+)
+def test_chart_type_schema_example_parses(example: dict[str, object]) -> None:
+    """Validate every schema-tool example against the discriminated union."""
+    TypeAdapter(ChartConfig).validate_python(example)
+
+
+def test_chart_configs_resource_examples_parse() -> None:
+    """Validate published resource examples with authentication isolated."""
+    from superset.mcp_service.chart.resources.chart_configs import (
+        get_chart_configs_resource,
+    )
+
+    with patch(
+        "superset.mcp_service.auth.get_user_from_request",
+        return_value=Mock(id=1, username="admin"),
+    ):
+        resource = json.loads(get_chart_configs_resource())
+    count = 0
+    for examples in resource.values():
+        if not isinstance(examples, dict):
+            continue
+        for example in examples.values():
+            if isinstance(example, dict) and "config" in example:
+                TypeAdapter(ChartConfig).validate_python(example["config"])
+                count += 1
+    assert count > 0
+    assert "project_schedule" in resource["gantt_chart_configs"]
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_helpers.py b/tests/unit_tests/mcp_service/chart/test_chart_helpers.py
index dda6cb12136..42c4ec2e72e 100644
--- a/tests/unit_tests/mcp_service/chart/test_chart_helpers.py
+++ b/tests/unit_tests/mcp_service/chart/test_chart_helpers.py
@@ -17,6 +17,8 @@
 
 from unittest.mock import MagicMock, patch
 
+import pytest
+
 from superset.mcp_service.chart.chart_helpers import (
     _deck_gl_null_filters,
     _is_metric_ref,
@@ -29,6 +31,7 @@ from superset.mcp_service.chart.chart_helpers import (
     merge_extra_form_data_filters_into_query,
     merge_form_data_filters_into_query,
     prepare_form_data_for_query,
+    resolve_big_number_columns,
     resolve_deck_gl_columns,
     resolve_metrics,
     resolve_metrics_and_groupby,
@@ -1040,3 +1043,285 @@ def test_resolve_metrics_and_groupby_non_singular_viz_type_uses_standard_resolut
     )
     assert metrics == ["count"]
     assert groupby == ["region"]
+
+
+@pytest.mark.parametrize(
+    ("form_data", "expected_columns"),
+    [
+        (
+            {
+                "viz_type": "big_number",
+                "granularity_sqla": "event_time",
+                "metric": "count",
+            },
+            ["event_time"],
+        ),
+        (
+            {
+                "viz_type": "big_number",
+                "x_axis": "recorded_at",
+                "granularity_sqla": "event_time",
+                "metric": "count",
+            },
+            ["recorded_at"],
+        ),
+        (
+            {
+                "viz_type": "big_number",
+                "x_axis": {"column_name": "created_at"},
+                "granularity_sqla": "event_time",
+                "metric": "count",
+            },
+            ["created_at"],
+        ),
+        (
+            {
+                "viz_type": "big_number",
+                "x_axis": {
+                    "expressionType": "SQL",
+                    "sqlExpression": "DATE_TRUNC('day', event_time)",
+                    "label": "event_day",
+                },
+                "granularity_sqla": "event_time",
+                "metric": "count",
+            },
+            [
+                {
+                    "expressionType": "SQL",
+                    "sqlExpression": "DATE_TRUNC('day', event_time)",
+                    "label": "event_day",
+                }
+            ],
+        ),
+    ],
+)
+def test_big_number_trendline_resolves_supported_time_column_aliases(
+    form_data, expected_columns
+):
+    assert resolve_big_number_columns(form_data) == expected_columns
+
+
+def test_big_number_total_does_not_add_temporal_dimension(monkeypatch):
+    monkeypatch.setattr(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        lambda datasource_id, datasource_type: "base",
+    )
+    query = build_query_dicts_from_form_data(
+        {
+            "viz_type": "big_number_total",
+            "granularity_sqla": "event_time",
+            "metric": "count",
+        },
+        1,
+        "table",
+    )[0]
+
+    assert query["columns"] == []
+    assert query["metrics"] == ["count"]
+
+
+def test_big_number_trendline_query_preserves_time_filter_and_aggregation(
+    monkeypatch,
+):
+    monkeypatch.setattr(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        lambda datasource_id, datasource_type: "base",
+    )
+    query = build_query_dicts_from_form_data(
+        {
+            "viz_type": "big_number",
+            "granularity_sqla": "event_time",
+            "metric": "count",
+            "aggregation": "raw",
+            "time_range": "Last week",
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "expressionType": "SIMPLE",
+                    "subject": "region",
+                    "operator": "==",
+                    "comparator": "EMEA",
+                }
+            ],
+        },
+        1,
+        "table",
+    )[0]
+
+    assert query == {
+        "columns": ["event_time"],
+        "metrics": ["count"],
+        "filters": [{"col": "region", "op": "==", "val": "EMEA"}],
+        "time_range": "Last week",
+    }
+
+
+@pytest.mark.parametrize(
+    ("form_data", "expected_queries"),
+    [
+        (
+            {
+                "viz_type": "echarts_timeseries_line",
+                "x_axis": "event_time",
+                "groupby": ["region"],
+                "metrics": ["count"],
+            },
+            [
+                {
+                    "columns": ["event_time", "region"],
+                    "metrics": ["count"],
+                    "filters": [],
+                }
+            ],
+        ),
+        (
+            {
+                "viz_type": "table",
+                "query_mode": "aggregate",
+                "groupby": ["region"],
+                "columns": ["stale_raw_column"],
+                "metrics": ["count"],
+            },
+            [{"columns": ["region"], "metrics": ["count"], "filters": []}],
+        ),
+        (
+            {"viz_type": "pie", "groupby": ["region"], "metric": "count"},
+            [{"columns": ["region"], "metrics": ["count"], "filters": []}],
+        ),
+        (
+            {
+                "viz_type": "pivot_table_v2",
+                "groupby": ["region", "product"],
+                "metrics": ["count"],
+            },
+            [
+                {
+                    "columns": ["region", "product"],
+                    "metrics": ["count"],
+                    "filters": [],
+                }
+            ],
+        ),
+        (
+            {
+                "viz_type": "mixed_timeseries",
+                "x_axis": "event_time",
+                "groupby": ["region"],
+                "metrics": ["count"],
+                "groupby_b": ["product"],
+                "metrics_b": ["sum_sales"],
+            },
+            [
+                {
+                    "columns": ["event_time", "region"],
+                    "metrics": ["count"],
+                    "filters": [],
+                },
+                {
+                    "columns": ["event_time", "product"],
+                    "metrics": ["sum_sales"],
+                    "filters": [],
+                },
+            ],
+        ),
+        (
+            {
+                "viz_type": "handlebars",
+                "query_mode": "raw",
+                "all_columns": ["region", "product"],
+            },
+            [
+                {
+                    "columns": ["region", "product"],
+                    "metrics": [],
+                    "filters": [],
+                }
+            ],
+        ),
+        (
+            {
+                "viz_type": "bubble",
+                "entity": "country",
+                "x": "sum_sales",
+                "y": "count",
+                "size": "avg_price",
+            },
+            [
+                {
+                    "columns": ["country"],
+                    "metrics": ["sum_sales", "count", "avg_price"],
+                    "filters": [],
+                }
+            ],
+        ),
+    ],
+)
+def test_shared_query_builder_preserves_pre_gantt_chart_contracts(
+    monkeypatch, form_data, expected_queries
+):
+    """Adding the Gantt branch must not alter established chart query shapes."""
+    monkeypatch.setattr(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        lambda datasource_id, datasource_type: "base",
+    )
+
+    assert (
+        build_query_dicts_from_form_data(dict(form_data), 1, "table")
+        == expected_queries
+    )
+
+
+def test_shared_query_builder_preserves_explicit_orderby() -> None:
+    """Keep explicit saved ordering through the shared preview/compile builder."""
+    form_data = {
+        "viz_type": "pie",
+        "groupby": ["region"],
+        "metrics": ["count"],
+        "orderby": [["count", True]],
+        "sort_by_metric": True,
+    }
+    with patch(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        return_value="base",
+    ):
+        query = build_query_dicts_from_form_data(form_data, 1, "table")[0]
+    assert query["orderby"] == [["count", True]]
+
+
+@pytest.mark.parametrize("secondary_orderby", [None, [["sum_sales", False]]])
+def test_shared_query_builder_keeps_mixed_timeseries_ordering_per_query(
+    secondary_orderby: list[list[str | bool]] | None,
+) -> None:
+    """Primary-only ordering must not leak into the secondary series query."""
+    form_data = {
+        "viz_type": "mixed_timeseries",
+        "x_axis": "event_time",
+        "groupby": ["region"],
+        "metrics": ["count"],
+        "orderby": [["count", True]],
+        "groupby_b": ["product"],
+        "metrics_b": ["sum_sales"],
+    }
+    if secondary_orderby is not None:
+        form_data["orderby_b"] = secondary_orderby
+    with patch(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        return_value="base",
+    ):
+        primary, secondary = build_query_dicts_from_form_data(form_data, 1, "table")
+
+    assert primary == {
+        "columns": ["event_time", "region"],
+        "metrics": ["count"],
+        "orderby": [["count", True]],
+        "filters": [],
+    }
+    expected_secondary: dict[str, object] = {
+        "columns": ["event_time", "product"],
+        "metrics": ["sum_sales"],
+        "filters": [],
+    }
+    if secondary_orderby is not None:
+        expected_secondary["orderby"] = secondary_orderby
+    assert secondary == expected_secondary
+    assert form_data["orderby"] == [["count", True]]
diff --git a/tests/unit_tests/mcp_service/chart/test_compile.py b/tests/unit_tests/mcp_service/chart/test_compile.py
index 65440b8353d..16eeeb8a673 100644
--- a/tests/unit_tests/mcp_service/chart/test_compile.py
+++ b/tests/unit_tests/mcp_service/chart/test_compile.py
@@ -28,6 +28,7 @@ from unittest.mock import Mock, patch
 import pytest
 
 from superset.mcp_service.chart.compile import (
+    _compile_chart,
     CompileResult,
     validate_and_compile,
 )
@@ -620,6 +621,90 @@ def test_compile_chart_returns_database_error_on_raw_sqlalchemy_error(
     assert result.error_obj.error_code == "DATABASE_CONNECTION_ERROR"
 
 
+@pytest.mark.parametrize(
+    "query_payload",
+    [
+        {"status": "FAILED", "message": "top-level failure", "queries": []},
+        {"error_message": "top-level failure", "queries": []},
+        {"queries": [{"status": "failed", "message": "boom", "data": []}]},
+        {
+            "queries": [
+                {"status": "success", "data": [{"value": 1}]},
+                {"status": "ERROR", "error_message": "second failed", "data": []},
+            ]
+        },
+    ],
+)
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.common.query_context_factory.QueryContextFactory")
+def test_compile_chart_rejects_embedded_query_failures(
+    mock_factory, mock_cmd_cls, query_payload
+):
+    mock_factory.return_value.create.return_value = Mock()
+    mock_cmd_cls.return_value.run.return_value = query_payload
+
+    result = _compile_chart({"viz_type": "table", "columns": ["value"]}, 1)
+
+    assert not result.success
+    assert result.error_code == "CHART_COMPILE_FAILED"
+    assert result.tier == "compile"
+    assert result.error_obj is not None
+    assert result.error_obj.error_type == "compile_error"
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.common.query_context_factory.QueryContextFactory")
+def test_compile_chart_allows_success_message_with_valid_empty_data(
+    mock_factory, mock_cmd_cls
+):
+    mock_factory.return_value.create.return_value = Mock()
+    mock_cmd_cls.return_value.run.return_value = {
+        "status": "success",
+        "message": "served from cache",
+        "queries": [{"status": "SUCCESS", "message": "no rows", "data": []}],
+    }
+
+    result = _compile_chart({"viz_type": "table", "columns": ["value"]}, 1)
+
+    assert result.success
+    assert result.row_count == 0
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.common.query_context_factory.QueryContextFactory")
+def test_compile_chart_big_number_uses_temporal_query_contract(
+    mock_factory, mock_cmd_cls
+):
+    mock_factory.return_value.create.return_value = Mock()
+    mock_cmd_cls.return_value.run.return_value = {"queries": [{"data": []}]}
+    form_data = {
+        "viz_type": "big_number",
+        "granularity_sqla": "event_time",
+        "metric": "count",
+        "aggregation": "LAST_VALUE",
+        "time_range": "Last week",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "region",
+                "operator": "==",
+                "comparator": "EMEA",
+            }
+        ],
+    }
+
+    result = _compile_chart(form_data, 1)
+
+    assert result.success
+    query = mock_factory.return_value.create.call_args.kwargs["queries"][0]
+    assert query["columns"] == ["event_time"]
+    assert query["metrics"] == ["count"]
+    assert query["time_range"] == "Last week"
+    assert query["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
+    assert query["row_limit"] == 2
+
+
 @pytest.mark.parametrize(
     "config_factory",
     [
diff --git a/tests/unit_tests/mcp_service/chart/test_gantt_chart.py b/tests/unit_tests/mcp_service/chart/test_gantt_chart.py
new file mode 100644
index 00000000000..e71a494c690
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_gantt_chart.py
@@ -0,0 +1,2812 @@
+# 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.
+
+"""Typed MCP coverage for the existing ECharts Gantt visualization."""
+
+import importlib
+from contextlib import nullcontext
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+import yaml
+from pydantic import TypeAdapter, ValidationError
+
+from superset.mcp_service.chart.chart_helpers import (
+    build_query_dicts_from_form_data,
+    resolve_gantt_query_fields,
+)
+from superset.mcp_service.chart.chart_utils import (
+    map_gantt_config,
+    merge_chart_form_data,
+    merge_gantt_ui_config,
+    validate_gantt_form_data,
+)
+from superset.mcp_service.chart.preview_utils import (
+    _generate_gantt_vega_lite_preview,
+    generate_preview_from_form_data,
+)
+from superset.mcp_service.chart.query_result import query_result_failure
+from superset.mcp_service.chart.schemas import (
+    ChartConfig,
+    ChartError,
+    GanttChartConfig,
+    GenerateChartRequest,
+    GetChartPreviewRequest,
+    TableChartConfig,
+    TablePreview,
+    UpdateChartPreviewRequest,
+    UpdateChartRequest,
+    VegaLitePreview,
+)
+from superset.mcp_service.chart.tool.get_chart_preview import (
+    VegaLitePreviewStrategy,
+)
+from superset.mcp_service.chart.tool.get_chart_type_schema import (
+    _get_chart_type_schema_impl,
+)
+from superset.mcp_service.chart.tool.update_chart import (
+    _build_preview_form_data,
+    _build_update_payload,
+)
+from superset.mcp_service.chart.validation.dataset_validator import (
+    DatasetValidator,
+    GanttSemanticNormalizationError,
+)
+from superset.mcp_service.chart.validation.pipeline import ValidationPipeline
+from superset.mcp_service.chart.validation.schema_validator import SchemaValidator
+from superset.mcp_service.common.error_schemas import DatasetContext
+
+update_chart_module = importlib.import_module(
+    "superset.mcp_service.chart.tool.update_chart"
+)
+generate_chart_module = importlib.import_module(
+    "superset.mcp_service.chart.tool.generate_chart"
+)
+update_chart_preview_module = importlib.import_module(
+    "superset.mcp_service.chart.tool.update_chart_preview"
+)
+
+
+def _config(**overrides: object) -> GanttChartConfig:
+    values: dict[str, object] = {
+        "chart_type": "gantt",
+        "start_time": {"name": "start_time"},
+        "end_time": {"name": "end_time"},
+        "category": {"name": "task"},
+    }
+    values.update(overrides)
+    return GanttChartConfig.model_validate(values)
+
+
+def _native_example() -> dict[str, object]:
+    document = yaml.safe_load(
+        Path("superset/examples/featured_charts/charts/Gantt.yaml").read_text()
+    )
+    return document["params"]
+
+
+def _dataset_context() -> DatasetContext:
+    return DatasetContext(
+        id=1,
+        table_name="tasks",
+        database_name="main",
+        available_columns=[
+            {"name": "Start_Time", "type": "TIMESTAMP", "is_temporal": True},
+            {"name": "End_Time", "type": "TIMESTAMP", "is_temporal": True},
+            {"name": "Task", "type": "VARCHAR", "is_temporal": False},
+            {"name": "Owner", "type": "VARCHAR", "is_temporal": False},
+            {"name": "Project", "type": "VARCHAR", "is_temporal": False},
+            {"name": "Priority", "type": "INTEGER", "is_temporal": False},
+        ],
+        available_metrics=[
+            {"name": "Completion", "expression": "AVG(progress)"},
+        ],
+    )
+
+
+def _ambiguous_dataset_context() -> DatasetContext:
+    return DatasetContext(
+        id=1,
+        table_name="ambiguous_tasks",
+        database_name="main",
+        available_columns=[
+            {"name": "StartedAt", "type": "VARCHAR", "is_temporal": False},
+            {"name": "startedat", "type": "TIMESTAMP", "is_temporal": True},
+            {"name": "EndedAt", "type": "TIMESTAMP", "is_temporal": True},
+            {"name": "Task", "type": "VARCHAR", "is_temporal": False},
+        ],
+        available_metrics=[],
+    )
+
+
+def test_gantt_discriminator_is_exposed_in_every_request_model() -> None:
+    payload = {
+        "chart_type": "gantt",
+        "start_time": {"name": "start_time"},
+        "end_time": {"name": "end_time"},
+        "category": {"name": "task"},
+    }
+    generated = GenerateChartRequest(dataset_id=1, config=payload)
+    updated = UpdateChartRequest(identifier=7, config=payload)
+    previewed = UpdateChartPreviewRequest(dataset_id=1, config=payload)
+    assert isinstance(generated.config, GanttChartConfig)
+    assert isinstance(updated.config, GanttChartConfig)
+    assert isinstance(previewed.config, GanttChartConfig)
+    union_config = TypeAdapter(ChartConfig).validate_python(payload)
+    assert isinstance(union_config, GanttChartConfig)
+
+
+def test_native_viz_type_alias_and_existing_type_regression() -> None:
+    request = GenerateChartRequest(
+        dataset_id=1,
+        config={
+            "viz_type": "gantt_chart",
+            "start_time": "start_time",
+            "end_time": "end_time",
+            "y_axis": "task",
+        },
+    )
+    assert isinstance(request.config, GanttChartConfig)
+    assert request.config.category.name == "task"
+    table = GenerateChartRequest(
+        dataset_id=1,
+        config={"chart_type": "table", "columns": [{"name": "task"}]},
+    )
+    assert isinstance(table.config, TableChartConfig)
+
+
+def test_schema_and_typo_protection() -> None:
+    schema_result = _get_chart_type_schema_impl("gantt")
+    assert schema_result["chart_type"] == "gantt"
+    schema = schema_result["schema"]
+    assert set(schema["required"]) >= {"start_time", "end_time", "category"}
+    assert schema["properties"]["chart_type"]["const"] == "gantt"
+    assert schema_result["examples"][0]["chart_type"] == "gantt"
+
+    with pytest.raises(ValidationError, match="did you mean 'start_time'"):
+        _config(start_tiem={"name": "wrong"})
+
+
+def test_role_and_cross_field_validation_is_conservative() -> None:
+    with pytest.raises(ValidationError, match="start_time.*dimension"):
+        _config(start_time={"name": "start_time", "aggregate": "MIN"})
+    with pytest.raises(ValidationError, match=r"tooltip_metrics\[0\]"):
+        _config(tooltip_metrics=[{"name": "progress"}])
+    with pytest.raises(ValidationError, match="requires series"):
+        _config(subcategories=True)
+    with pytest.raises(ValidationError, match="different columns"):
+        _config(end_time={"name": "start_time"})
+
+    # These roles resolve labels case-insensitively in dataset canonicalization.
+    with pytest.raises(ValidationError, match="start_time and end_time"):
+        _config(end_time={"name": "START_TIME"})
+    with pytest.raises(ValidationError, match="start_time and category"):
+        _config(category={"name": "START_TIME"})
+    with pytest.raises(ValidationError, match="end_time and category"):
+        _config(category={"name": "END_TIME"})
+    with pytest.raises(ValidationError, match="series and category"):
+        _config(series={"name": "task"})
+    with pytest.raises(ValidationError, match="series and category"):
+        _config(series={"name": "TASK"}, subcategories=True)
+
+    with pytest.raises(ValidationError, match=r"tooltip_columns\[0\].*dimension"):
+        _config(tooltip_columns=[{"name": "Task", "aggregate": "MAX"}])
+    with pytest.raises(ValidationError, match=r"tooltip_metrics\[0\]"):
+        _config(tooltip_metrics=[{"name": "TASK"}])
+
+    # The frontend permits a physical column in a tooltip as either a repeated
+    # dimension or an aggregated metric; only the role shape must stay distinct.
+    config = _config(
+        tooltip_columns=[{"name": "TASK"}],
+        tooltip_metrics=[{"name": "task", "aggregate": "COUNT"}],
+    )
+    assert config.tooltip_columns[0].name == "TASK"
+    assert config.tooltip_metrics[0].aggregate == "COUNT"
+
+
+@pytest.mark.parametrize("request_model", [GenerateChartRequest, UpdateChartRequest])
+@pytest.mark.parametrize("generate_preview", [False, True])
+def test_product_request_models_reject_case_insensitive_required_role_collisions(
+    request_model: type[GenerateChartRequest] | type[UpdateChartRequest],
+    generate_preview: bool,
+) -> None:
+    config = {
+        "chart_type": "gantt",
+        "start_time": {"name": "StartedAt"},
+        "end_time": {"name": "EndedAt"},
+        "category": {"name": "STARTEDAT"},
+    }
+    kwargs: dict[str, object] = {
+        "config": config,
+        "generate_preview": generate_preview,
+    }
+    if request_model is GenerateChartRequest:
+        kwargs["dataset_id"] = 1
+    else:
+        kwargs["identifier"] = 1495
+
+    with pytest.raises(ValidationError, match="start_time and category"):
+        request_model(**kwargs)
+
+
+@pytest.mark.parametrize("generate_preview", [False, True])
+def test_update_preview_request_rejects_case_insensitive_role_collisions(
+    generate_preview: bool,
+) -> None:
+    with pytest.raises(ValidationError, match="end_time and category"):
+        UpdateChartPreviewRequest(
+            dataset_id=1,
+            generate_preview=generate_preview,
+            config={
+                "chart_type": "gantt",
+                "start_time": {"name": "StartedAt"},
+                "end_time": {"name": "EndedAt"},
+                "category": {"name": "ENDEDAT"},
+            },
+        )
+
+
+def test_preview_required_role_uniqueness_is_case_insensitive() -> None:
+    result = _generate_gantt_vega_lite_preview(
+        [],
+        {
+            "viz_type": "gantt_chart",
+            "start_time": "StartedAt",
+            "end_time": "EndedAt",
+            "y_axis": "STARTEDAT",
+        },
+    )
+    assert isinstance(result, ChartError)
+    assert result.error_type == "InvalidGanttFormData"
+    assert "distinct" in result.error
+
+
+def test_native_input_rejects_malformed_filters_order_and_bounds() -> None:
+    with pytest.raises(ValidationError, match="free-form SQL"):
+        GanttChartConfig.model_validate(
+            {
+                **_config().model_dump(exclude={"filters"}),
+                "adhoc_filters": [
+                    {"expressionType": "SQL", "sqlExpression": "task IS NOT NULL"}
+                ],
+            }
+        )
+    with pytest.raises(ValidationError, match="not valid JSON"):
+        _config(order_by_cols=["not-json"])
+    with pytest.raises(ValidationError, match="HH:MM:SS"):
+        _config(x_axis_time_bounds=["8am", None])
+    with pytest.raises(ValidationError, match="Unrecognized time_range"):
+        _config(time_range="whenever")
+
+
+def test_real_saved_example_adapts_and_round_trips_presentation() -> None:
+    config = GanttChartConfig.model_validate(_native_example())
+    assert isinstance(
+        GenerateChartRequest(dataset_id=61, config=_native_example()).config,
+        GanttChartConfig,
+    )
+    assert isinstance(
+        UpdateChartRequest(identifier=1495, config=_native_example()).config,
+        GanttChartConfig,
+    )
+    assert isinstance(
+        UpdateChartPreviewRequest(dataset_id=61, config=_native_example()).config,
+        GanttChartConfig,
+    )
+    assert config.temporal_column == "start_time"
+    assert config.category.name == "status"
+    assert config.series is not None
+    assert config.series.name == "priority"
+    assert config.order_by[0].column == "status"
+    assert config.order_by[0].ascending is False
+
+    form_data = map_gantt_config(config)
+    assert form_data["viz_type"] == "gantt_chart"
+    assert form_data["start_time"] == "start_time"
+    assert form_data["end_time"] == "end_time"
+    assert form_data["y_axis"] == "status"
+    assert form_data["series"] == "priority"
+    assert form_data["tooltip_columns"] == ["project", "phase"]
+    assert form_data["order_by_cols"] == ['["status", false]']
+    assert form_data["legendOrientation"] == "right"
+    assert form_data["legendMargin"] == 100
+    assert form_data["x_axis_time_bounds"] == ["08:00:00", "19:00:00"]
+    assert form_data["tooltipTimeFormat"] == "smart_date"
+    assert form_data["adhoc_filters"] == [
+        {
+            "clause": "WHERE",
+            "expressionType": "SIMPLE",
+            "subject": "start_time",
+            "operator": "TEMPORAL_RANGE",
+            "comparator": "No filter",
+        }
+    ]
+
+
+def test_mapper_output_round_trips_saved_adhoc_and_sql_tooltip_metrics() -> None:
+    original = map_gantt_config(
+        _config(
+            tooltip_metrics=[
+                {"name": "Completion", "saved_metric": True},
+                {"name": "cost", "aggregate": "SUM"},
+                {"name": "hours", "aggregate": "AVG", "label": "Avg hours"},
+                {"sql_expression": "SUM(cost) / COUNT(*)", "label": "Unit cost"},
+            ],
+            time_range="Last 30 days",
+        )
+    )
+
+    adapted = GanttChartConfig.model_validate(original)
+    assert adapted.tooltip_metrics[0].saved_metric is True
+    assert adapted.tooltip_metrics[1].aggregate == "SUM"
+    assert adapted.tooltip_metrics[1].label is None
+    assert adapted.tooltip_metrics[2].label == "Avg hours"
+    assert adapted.tooltip_metrics[3].sql_expression == "SUM(cost) / COUNT(*)"
+    assert map_gantt_config(adapted) == original
+
+    cached = dict(original)
+    cached_metric = dict(original["tooltip_metrics"][1])
+    cached_metric["column"] = {
+        "advanced_data_type": None,
+        "certification_details": None,
+        "certified_by": None,
+        "column_name": "cost",
+        "description": "Task cost",
+        "expression": None,
+        "filterable": True,
+        "groupby": True,
+        "id": 734,
+        "is_certified": False,
+        "is_dttm": False,
+        "python_date_format": None,
+        "type": "DOUBLE PRECISION",
+        "type_generic": 0,
+        "verbose_name": None,
+        "warning_markdown": None,
+    }
+    cached["tooltip_metrics"] = [cached_metric]
+    normalized_cached = map_gantt_config(GanttChartConfig.model_validate(cached))
+    assert normalized_cached["tooltip_metrics"] == [original["tooltip_metrics"][1]]
+    GanttChartConfig.model_validate(normalized_cached)
+
+    malformed = dict(original)
+    malformed_metric = dict(original["tooltip_metrics"][1])
+    malformed_metric["expressonType"] = malformed_metric.pop("expressionType")
+    malformed["tooltip_metrics"] = [malformed_metric]
+    with pytest.raises(ValidationError, match="expressonType"):
+        GanttChartConfig.model_validate(malformed)
+
+
+def test_native_adapter_accepts_bounded_standard_explore_metadata() -> None:
+    native = map_gantt_config(_config())
+    native["adhoc_filters"] = [
+        {
+            "clause": "WHERE",
+            "comparator": "Build",
+            "datasourceWarning": False,
+            "expressionType": "SIMPLE",
+            "filterOptionName": "filter_123",
+            "isExtra": False,
+            "isNew": False,
+            "operator": "==",
+            "operatorId": "EQUALS",
+            "sqlExpression": None,
+            "subject": "task",
+        },
+        {
+            "clause": "WHERE",
+            "comparator": "Last 30 days",
+            "datasourceWarning": False,
+            "expressionType": "SIMPLE",
+            "filterOptionName": "filter_456",
+            "isExtra": True,
+            "isNew": False,
+            "operator": "TEMPORAL_RANGE",
+            "operatorId": "TEMPORAL_RANGE",
+            "sqlExpression": None,
+            "subject": "start_time",
+        },
+    ]
+    native["tooltip_metrics"] = [
+        {
+            "aggregate": "SUM",
+            "column": {
+                "advanced_data_type": None,
+                "certification_details": None,
+                "certified_by": "Data team",
+                "column_name": "cost",
+                "database_expression": None,
+                "description": "Task cost",
+                "expression": None,
+                "filterBy": "cost",
+                "filterable": True,
+                "groupby": False,
+                "id": 734,
+                "is_certified": True,
+                "is_dttm": False,
+                "optionName": "cost",
+                "python_date_format": None,
+                "type": "DOUBLE PRECISION",
+                "type_generic": 0,
+                "uuid": None,
+                "value": "cost",
+                "verbose_name": None,
+                "warning_markdown": "Certified source",
+            },
+            "datasourceWarning": False,
+            "expressionType": "SIMPLE",
+            "hasCustomLabel": False,
+            "label": "SUM(cost)",
+            "optionName": "metric_cost",
+            "sqlExpression": None,
+        }
+    ]
+
+    request = UpdateChartRequest(identifier=1495, config=native)
+    assert isinstance(request.config, GanttChartConfig)
+    adapted = request.config
+
+    assert adapted.filters is not None
+    assert adapted.filters[0].column == "task"
+    assert adapted.time_range == "Last 30 days"
+    assert adapted.tooltip_metrics[0].name == "cost"
+    remapped = map_gantt_config(adapted)
+    assert remapped["tooltip_metrics"] == [
+        {
+            "aggregate": "SUM",
+            "column": {"column_name": "cost"},
+            "datasourceWarning": False,
+            "expressionType": "SIMPLE",
+            "hasCustomLabel": False,
+            "label": "SUM(cost)",
+            "optionName": "metric_cost",
+            "sqlExpression": None,
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    ("operator_id", "operator", "comparator", "expected_op"),
+    [
+        ("EQUALS", "==", "Build", "="),
+        ("NOT_EQUALS", "!=", "Build", "!="),
+        ("LESS_THAN", "<", 5, "<"),
+        ("LESS_THAN_OR_EQUAL", "<=", 5, "<="),
+        ("GREATER_THAN", ">", 5, ">"),
+        ("GREATER_THAN_OR_EQUAL", ">=", 5, ">="),
+        ("IN", "IN", ["Build", "Review"], "IN"),
+        ("NOT_IN", "NOT IN", ["Deferred"], "NOT IN"),
+        ("LIKE", "LIKE", "Build%", "LIKE"),
+        ("ILIKE", "ILIKE", "build%", "ILIKE"),
+        ("IS_NULL", "IS NULL", None, "IS NULL"),
+        ("IS_NOT_NULL", "IS NOT NULL", None, "IS NOT NULL"),
+    ],
+)
+def test_native_adapter_maps_supported_frontend_filter_operators(
+    operator_id: str,
+    operator: str,
+    comparator: object,
+    expected_op: str,
+) -> None:
+    native = map_gantt_config(_config())
+    native.pop("_mcp_dashboard_time_filter_subject")
+    native["adhoc_filters"] = [
+        {
+            "clause": "WHERE",
+            "comparator": comparator,
+            "expressionType": "SIMPLE",
+            "operator": operator,
+            "operatorId": operator_id,
+            "sqlExpression": None,
+            "subject": "task",
+        }
+    ]
+
+    request = UpdateChartRequest(identifier=1495, config=native)
+
+    assert isinstance(request.config, GanttChartConfig)
+    assert request.config.filters is not None
+    assert request.config.filters[0].op == expected_op
+    assert request.config.filters[0].value == comparator
+
+
+def test_native_adapter_maps_valid_temporal_operator_id() -> None:
+    native = map_gantt_config(_config())
+    native["adhoc_filters"] = [
+        {
+            "clause": "WHERE",
+            "comparator": "Last 30 days",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "operatorId": "TEMPORAL_RANGE",
+            "sqlExpression": None,
+            "subject": "start_time",
+        }
+    ]
+
+    request = UpdateChartRequest(identifier=1495, config=native)
+
+    assert isinstance(request.config, GanttChartConfig)
+    assert request.config.filters is None
+    assert request.config.temporal_column == "start_time"
+    assert request.config.time_range == "Last 30 days"
+
+
+@pytest.mark.parametrize(
+    ("operator_id", "operator", "comparator", "message"),
+    [
+        ("EQAULS", "==", "Build", "not a recognized Explore operator ID"),
+        ("EQUALS", "!=", "Build", "contradictory operator"),
+        ("IS_NULL", "==", "Build", "contradictory operator"),
+        ("IS_NULL", "IS NULL", "Build", "must not define comparator"),
+        ("LATEST_PARTITION", "LATEST PARTITION", None, "not supported"),
+        ("IN", "IN", "Build", "non-empty comparator array"),
+    ],
+)
+def test_native_adapter_rejects_malformed_or_unsupported_operator_shapes(
+    operator_id: str,
+    operator: str,
+    comparator: object,
+    message: str,
+) -> None:
+    native = map_gantt_config(_config())
+    native.pop("_mcp_dashboard_time_filter_subject")
+    native["adhoc_filters"] = [
+        {
+            "clause": "WHERE",
+            "comparator": comparator,
+            "expressionType": "SIMPLE",
+            "operator": operator,
+            "operatorId": operator_id,
+            "sqlExpression": None,
+            "subject": "task",
+        }
+    ]
+
+    with pytest.raises(ValidationError, match=message):
+        UpdateChartRequest(identifier=1495, config=native)
+
+
+@pytest.mark.parametrize(
+    ("field", "value", "message"),
+    [
+        ("operator", {}, "operator must be a non-empty string"),
+        ("operator", [], "operator must be a non-empty string"),
+        ("operator", 7, "operator must be a non-empty string"),
+        ("operator", None, "operator must be a non-empty string"),
+        ("operatorId", {}, "operatorId must be a non-empty string"),
+        ("operatorId", [], "operatorId must be a non-empty string"),
+        ("operatorId", 7, "operatorId must be a non-empty string"),
+        ("comparator", {}, "requires a scalar comparator"),
+        ("comparator", [], "requires a scalar comparator"),
+        ("comparator", None, "requires a scalar comparator"),
+    ],
+)
+def test_native_filter_unhashable_values_are_structured_validation_errors(
+    field: str, value: object, message: str
+) -> None:
+    native = map_gantt_config(_config())
+    native["chart_type"] = "gantt"
+    native.pop("_mcp_dashboard_time_filter_subject")
+    native_filter: dict[str, object] = {
+        "clause": "WHERE",
+        "comparator": "Build",
+        "expressionType": "SIMPLE",
+        "operator": "==",
+        "operatorId": "EQUALS",
+        "sqlExpression": None,
+        "subject": "task",
+    }
+    native_filter[field] = value
+    native["adhoc_filters"] = [native_filter]
+
+    valid, _request, error = SchemaValidator.validate_request(
+        {"dataset_id": 1, "config": native}
+    )
+
+    assert valid is False
+    assert error is not None
+    assert error.error_type != "validation_system_error"
+    assert message in f"{error.message} {error.details}"
+
+
+@pytest.mark.parametrize(
+    ("field", "value"),
+    [
+        ("advanced_data_type", []),
+        ("certification_details", {}),
+        ("certified_by", 3),
+        ("description", []),
+        ("filterable", "true"),
+        ("groupby", "definitely-not-bool"),
+        ("id", True),
+        ("is_certified", None),
+        ("is_dttm", 0),
+        ("python_date_format", False),
+        ("type", 3),
+        ("type_generic", "0"),
+        ("type_generic", 5),
+        ("uuid", 123),
+        ("verbose_name", []),
+        ("warning_markdown", {}),
+    ],
+)
+def test_update_request_rejects_wrongly_typed_native_column_metadata(
+    field: str, value: object
+) -> None:
+    native = map_gantt_config(_config())
+    native["tooltip_metrics"] = [
+        {
+            "aggregate": "SUM",
+            "column": {"column_name": "cost", field: value},
+            "expressionType": "SIMPLE",
+            "label": "SUM(cost)",
+            "sqlExpression": None,
+        }
+    ]
+
+    with pytest.raises(ValidationError, match=field):
+        UpdateChartRequest(identifier=1495, config=native)
+
+
+def test_native_adapter_uses_frontend_fallback_for_unlabeled_sql_metric() -> None:
+    native = map_gantt_config(_config())
+    native["tooltip_metrics"] = [
+        {
+            "aggregate": None,
+            "column": None,
+            "datasourceWarning": False,
+            "expressionType": "SQL",
+            "hasCustomLabel": False,
+            "isNew": False,
+            "label": None,
+            "optionName": "metric_sql_123",
+            "sqlExpression": "SUM(cost) / COUNT(*)",
+        }
+    ]
+
+    adapted = GanttChartConfig.model_validate(native)
+    metric = adapted.tooltip_metrics[0]
+
+    assert metric.sql_expression == "SUM(cost) / COUNT(*)"
+    assert metric.label == "SUM(cost) / COUNT(*)"
+    assert map_gantt_config(adapted)["tooltip_metrics"][0]["label"] == metric.label
+
+
+@pytest.mark.parametrize(
+    ("container", "field", "value", "message"),
+    [
+        ("filter", "filterOptionName", 123, "filterOptionName"),
+        ("filter", "isExtra", "false", "isExtra"),
+        ("filter", "sqlExpression", "task = 'x'", "must be null"),
+        ("filter", "filterOptionNmae", "typo", "filterOptionNmae"),
+        ("metric", "isNew", "false", "isNew"),
+        ("metric", "isNwe", False, "isNwe"),
+    ],
+)
+def test_native_adapter_rejects_malformed_or_unknown_explore_metadata(
+    container: str, field: str, value: object, message: str
+) -> None:
+    native = map_gantt_config(_config())
+    if container == "filter":
+        target = native["adhoc_filters"][0]
+    else:
+        native["tooltip_metrics"] = [
+            {
+                "aggregate": "SUM",
+                "column": {"column_name": "cost"},
+                "expressionType": "SIMPLE",
+                "label": "SUM(cost)",
+                "sqlExpression": None,
+            }
+        ]
+        target = native["tooltip_metrics"][0]
+    target[field] = value
+
+    with pytest.raises(ValidationError, match=message):
+        GanttChartConfig.model_validate(native)
+
+
+def test_native_temporal_filters_reject_multiple_or_conflicting_bindings() -> None:
+    base = map_gantt_config(_config())
+    second = {
+        "clause": "WHERE",
+        "expressionType": "SIMPLE",
+        "subject": "end_time",
+        "operator": "TEMPORAL_RANGE",
+        "comparator": "No filter",
+    }
+    for filters in (
+        [*base["adhoc_filters"], second],
+        [*base["adhoc_filters"], dict(base["adhoc_filters"][0])],
+    ):
+        with pytest.raises(ValidationError, match="at most one TEMPORAL_RANGE"):
+            GanttChartConfig.model_validate({**base, "adhoc_filters": filters})
+
+    with pytest.raises(
+        ValidationError, match="_mcp_dashboard_time_filter_subject conflicts"
+    ):
+        GanttChartConfig.model_validate(
+            {**base, "_mcp_dashboard_time_filter_subject": "end_time"}
+        )
+    with pytest.raises(ValidationError, match="time_range conflicts"):
+        GanttChartConfig.model_validate(
+            {
+                **base,
+                "time_range": "Last 7 days",
+                "adhoc_filters": [
+                    {**base["adhoc_filters"][0], "comparator": "Last 30 days"}
+                ],
+            }
+        )
+
+    one_filter = GanttChartConfig.model_validate(base)
+    assert map_gantt_config(one_filter) == base
+
+
+def test_typed_mapping_matches_frontend_build_query_contract() -> None:
+    form_data = map_gantt_config(
+        _config(
+            series={"name": "owner"},
+            subcategories=True,
+            tooltip_columns=[{"name": "project"}],
+            tooltip_metrics=[{"name": "Completion", "saved_metric": True}],
+            order_by=[
+                {"column": "start_time", "ascending": True},
+                {"column": "priority", "ascending": False},
+            ],
+            filters=[{"column": "owner", "op": "=", "value": "Amin"}],
+            time_range="Last 30 days",
+            color_scheme="supersetColors",
+        )
+    )
+    assert form_data["tooltip_metrics"] == ["Completion"]
+    assert form_data["adhoc_filters"][0] == {
+        "clause": "WHERE",
+        "expressionType": "SIMPLE",
+        "subject": "owner",
+        "operator": "==",
+        "comparator": "Amin",
+    }
+    assert form_data["time_range"] == "Last 30 days"
+    assert form_data["subcategories"] is True
+    temporal_filter = next(
+        filter_
+        for filter_ in form_data["adhoc_filters"]
+        if filter_["operator"] == "TEMPORAL_RANGE"
+    )
+    assert temporal_filter["subject"] == "start_time"
+    assert temporal_filter["comparator"] == "Last 30 days"
+
+
+def test_query_fields_and_query_context_shape_match_frontend() -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+        "series": "owner",
+        "tooltip_columns": ["project"],
+        "tooltip_metrics": ["Completion"],
+        "order_by_cols": [
+            '["start_time", true]',
+            '["priority", false]',
+        ],
+        "row_limit": 321,
+    }
+    columns, metrics, orderby, series_columns = resolve_gantt_query_fields(form_data)
+    assert columns == [
+        "start_time",
+        "end_time",
+        "task",
+        "owner",
+        "project",
+        "priority",
+    ]
+    assert metrics == ["Completion"]
+    assert orderby == [["start_time", True], ["priority", False]]
+    assert series_columns == ["owner"]
+
+    with patch(
+        "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+        return_value="base",
+    ):
+        query = build_query_dicts_from_form_data(form_data, 1, "table")[0]
+    assert query["columns"] == columns
+    assert query["metrics"] == metrics
+    assert query["orderby"] == orderby
+    assert query["series_columns"] == series_columns
+    assert query["row_limit"] == 321
+
+
+def test_saved_query_fallback_rejects_malformed_native_order() -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+        "order_by_cols": [["start_time", "yes"]],
+    }
+    with pytest.raises(ValueError, match="ascending_boolean"):
+        resolve_gantt_query_fields(form_data)
+
+
+def test_dataset_validation_and_case_normalization() -> None:
+    config = _config(
+        series={"name": "owner"},
+        tooltip_columns=[{"name": "project"}],
+        tooltip_metrics=[{"name": "completion", "saved_metric": True}],
+        order_by=[{"column": "priority", "ascending": False}],
+    )
+    valid, error = DatasetValidator.validate_against_dataset(
+        config, 1, dataset_context=_dataset_context()
+    )
+    assert valid is True
+    assert error is None
+
+    normalized = DatasetValidator.normalize_column_names(
+        config, 1, dataset_context=_dataset_context()
+    )
+    assert normalized.start_time.name == "Start_Time"
+    assert normalized.end_time.name == "End_Time"
+    assert normalized.category.name == "Task"
+    assert normalized.series is not None
+    assert normalized.series.name == "Owner"
+    assert normalized.tooltip_metrics[0].name == "Completion"
+    assert normalized.order_by[0].column == "Priority"
+
+    missing = _config(category={"name": "tasx"})
+    valid, error = DatasetValidator.validate_against_dataset(
+        missing, 1, dataset_context=_dataset_context()
+    )
+    assert valid is False
+    assert error is not None
+    assert any("Task" in suggestion for suggestion in error.suggestions)
+
+
+def test_gantt_normalization_rejects_ambiguous_case_insensitive_dataset_names() -> None:
+    config = _config(
+        start_time={"name": "STARTEDAT"},
+        end_time={"name": "endedat"},
+        category={"name": "task"},
+    )
+
+    with pytest.raises(
+        GanttSemanticNormalizationError,
+        match="ambiguous.*StartedAt.*startedat.*exact physical column name",
+    ):
+        DatasetValidator.normalize_column_names(
+            config, 1, dataset_context=_ambiguous_dataset_context()
+        )
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_exact_case_wins_for_every_gantt_reference_and_temporal_check(
+    reverse_metadata: bool,
+) -> None:
+    """Metadata order cannot redirect exact refs to a case-colliding column."""
+    context = _ambiguous_dataset_context()
+    if reverse_metadata:
+        context.available_columns[0:2] = reversed(context.available_columns[0:2])
+    context.available_metrics = [
+        {"name": "Budget", "expression": "SUM(cost)"},
+        {"name": "budget", "expression": "AVG(cost)"},
+    ]
+    config = _config(
+        start_time={"name": "startedat"},
+        end_time={"name": "EndedAt"},
+        category={"name": "Task"},
+        series={"name": "StartedAt"},
+        tooltip_columns=[{"name": "startedat"}],
+        tooltip_metrics=[{"name": "budget", "saved_metric": True}],
+        temporal_column="startedat",
+        filters=[{"column": "startedat", "op": ">=", "value": "2026-01-01"}],
+        order_by=[{"column": "startedat", "ascending": True}],
+    )
+
+    valid, error = DatasetValidator.validate_against_dataset(
+        config, 1, dataset_context=context
+    )
+    assert valid is True
+    assert error is None
+    normalized = DatasetValidator.normalize_column_names(
+        config, 1, dataset_context=context
+    )
+    assert normalized.start_time.name == "startedat"
+    assert normalized.series is not None
+    assert normalized.series.name == "StartedAt"
+    assert normalized.tooltip_columns[0].name == "startedat"
+    assert normalized.tooltip_metrics[0].name == "budget"
+    assert normalized.temporal_column == "startedat"
+    assert normalized.filters is not None
+    assert normalized.filters[0].column == "startedat"
+    assert normalized.order_by[0].column == "startedat"
+
+    plugin = __import__("superset.mcp_service.chart.registry", fromlist=["get"]).get(
+        "gantt"
+    )
+    assert plugin is not None
+    with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+        assert plugin.post_map_validate(normalized, {}, dataset_id=1) is None
+        wrong_case_error = plugin.post_map_validate(
+            _config(start_time={"name": "StartedAt"}), {}, dataset_id=1
+        )
+    assert wrong_case_error is not None
+    assert wrong_case_error.error_code == "NON_TEMPORAL_GANTT_TIME_COLUMN"
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_ambiguous_nonexact_reference_fails_validation_actionably(
+    reverse_metadata: bool,
+) -> None:
+    context = _ambiguous_dataset_context()
+    if reverse_metadata:
+        context.available_columns[0:2] = reversed(context.available_columns[0:2])
+    config = _config(start_time={"name": "STARTEDAT"})
+
+    valid, error = DatasetValidator.validate_against_dataset(
+        config, 1, dataset_context=context
+    )
+
+    assert valid is False
+    assert error is not None
+    assert error.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+    assert "StartedAt" in error.details
+    assert "startedat" in error.details
+    assert "exact physical column name" in error.details
+
+
+def test_validation_pipeline_fails_closed_on_ambiguous_gantt_reference() -> None:
+    request_data = {
+        "dataset_id": 1,
+        "config": {
+            "chart_type": "gantt",
+            "start_time": {"name": "STARTEDAT"},
+            "end_time": {"name": "endedat"},
+            "category": {"name": "task"},
+        },
+    }
+    with (
+        patch.object(
+            ValidationPipeline,
+            "_get_dataset_context",
+            return_value=_ambiguous_dataset_context(),
+        ),
+        patch.object(
+            ValidationPipeline, "_validate_runtime", return_value=(True, None)
+        ),
+    ):
+        result = ValidationPipeline.validate_request_with_warnings(request_data)
+
+    assert result.is_valid is False
+    assert result.request is not None
+    assert result.error is not None
+    assert result.error.error_type == "ambiguous_dataset_reference"
+    assert result.error.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+    assert "ambiguous" in result.error.details
+    assert result.error.suggestions
+
+
+def test_nonsemantic_normalization_error_remains_a_warning_for_other_charts(
+    caplog: pytest.LogCaptureFixture,
+) -> None:
+    request = GenerateChartRequest(
+        dataset_id=1,
+        config={"chart_type": "table", "columns": [{"name": "Task"}]},
+    )
+    with patch.object(
+        DatasetValidator,
+        "normalize_column_names",
+        side_effect=ValueError("temporary canonicalization issue"),
+    ):
+        normalized = ValidationPipeline._normalize_column_names(request)
+
+    assert normalized is request
+    assert "Column name normalization failed" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_generate_chart_maps_ambiguous_gantt_reference_to_error() -> None:
+    request = GenerateChartRequest(
+        dataset_id=1,
+        config={
+            "chart_type": "gantt",
+            "start_time": {"name": "STARTEDAT"},
+            "end_time": {"name": "endedat"},
+            "category": {"name": "task"},
+        },
+        preview_formats=[],
+    )
+    ctx = Mock(
+        info=AsyncMock(),
+        debug=AsyncMock(),
+        warning=AsyncMock(),
+        error=AsyncMock(),
+        report_progress=AsyncMock(),
+    )
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            ValidationPipeline,
+            "_get_dataset_context",
+            return_value=_ambiguous_dataset_context(),
+        ),
+        patch.object(
+            ValidationPipeline, "_validate_runtime", return_value=(True, None)
+        ),
+        patch.object(generate_chart_module, "map_config_to_form_data") as mapper,
+    ):
+        result = await generate_chart_module.generate_chart(request=request, ctx=ctx)
+
+    assert result.success is False
+    assert result.error is not None
+    assert result.error.error_type == "ambiguous_dataset_reference"
+    assert "ambiguous" in result.error.details
+    mapper.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_update_chart_maps_gantt_semantic_normalization_to_error() -> None:
+    request = UpdateChartRequest(
+        identifier=1495,
+        config={
+            "chart_type": "gantt",
+            "start_time": {"name": "STARTEDAT"},
+            "end_time": {"name": "endedat"},
+            "category": {"name": "task"},
+        },
+        generate_preview=False,
+        preview_formats=[],
+    )
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=1,
+        datasource=object(),
+        params=__import__("json").dumps(_native_example()),
+        slice_name="Gantt",
+        uuid="chart-uuid",
+        viz_type="gantt_chart",
+    )
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            update_chart_module, "find_chart_by_identifier", return_value=chart
+        ),
+        patch(
+            "superset.mcp_service.auth.check_chart_data_access",
+            return_value=SimpleNamespace(is_valid=True, error=None),
+        ),
+        patch.object(
+            DatasetValidator,
+            "_get_dataset_context",
+            return_value=_ambiguous_dataset_context(),
+        ),
+        patch("superset.commands.chart.update.UpdateChartCommand") as command,
+    ):
+        result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+    assert result.success is False
+    assert result.error is not None
+    assert result.error.error_type == "ValidationError"
+    assert "ambiguous" in result.error.details
+    command.assert_not_called()
+
+
+def test_update_chart_preview_maps_gantt_semantic_normalization_to_error() -> None:
+    context = _ambiguous_dataset_context()
+    columns = [
+        SimpleNamespace(
+            column_name=column["name"],
+            type=column["type"],
+            is_temporal=column["is_temporal"],
+            is_numeric=False,
+        )
+        for column in context.available_columns
+    ]
+    dataset = SimpleNamespace(
+        id=1,
+        table_name=context.table_name,
+        schema=None,
+        columns=columns,
+        metrics=[],
+        database=SimpleNamespace(database_name="main", db_engine_spec=None),
+    )
+    request = UpdateChartPreviewRequest(
+        dataset_id=1,
+        config={
+            "chart_type": "gantt",
+            "start_time": {"name": "STARTEDAT"},
+            "end_time": {"name": "endedat"},
+            "category": {"name": "task"},
+        },
+        preview_formats=[],
+    )
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            update_chart_preview_module, "_find_dataset", return_value=dataset
+        ),
+        patch.object(update_chart_preview_module, "generate_explore_link") as link,
+    ):
+        result = update_chart_preview_module.update_chart_preview(request, ctx=Mock())
+
+    assert result["success"] is False
+    assert result["error"]["error_type"] == "gantt_semantic_validation_error"
+    assert "ambiguous" in result["error"]["details"]
+    link.assert_not_called()
+
+
+@pytest.mark.parametrize("generate_query_preview", [False, True])
+def test_update_chart_preview_normalizes_all_gantt_refs_before_url_and_query_preview(
+    generate_query_preview: bool,
+) -> None:
+    columns = [
+        SimpleNamespace(
+            column_name=column["name"],
+            type=column["type"],
+            is_temporal=column["is_temporal"],
+            is_numeric=False,
+        )
+        for column in _dataset_context().available_columns
+    ]
+    metrics = [
+        SimpleNamespace(
+            metric_name="Completion",
+            expression="AVG(progress)",
+            description=None,
+        )
+    ]
+    dataset = SimpleNamespace(
+        id=1,
+        table_name="tasks",
+        schema=None,
+        columns=columns,
+        metrics=metrics,
+        database=SimpleNamespace(database_name="main", db_engine_spec=None),
+    )
+    request = UpdateChartPreviewRequest(
+        dataset_id=1,
+        config=_config(
+            start_time={"name": "start_time"},
+            end_time={"name": "end_time"},
+            category={"name": "task"},
+            series={"name": "owner"},
+            temporal_column="end_time",
+            tooltip_columns=[{"name": "project"}],
+            tooltip_metrics=[{"name": "completion", "saved_metric": True}],
+            filters=[{"column": "owner", "op": "=", "value": "Amin"}],
+            order_by=[{"column": "priority", "ascending": False}],
+            time_range="Last 7 days",
+        ),
+        generate_preview=generate_query_preview,
+        preview_formats=["vega_lite"],
+    )
+
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            update_chart_preview_module, "_find_dataset", return_value=dataset
+        ),
+        patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+        patch.object(
+            update_chart_preview_module, "has_dataset_access", return_value=True
+        ),
+        patch.object(update_chart_preview_module, "validate_and_compile") as validate,
+        patch.object(
+            update_chart_preview_module,
+            "generate_explore_link",
+            return_value="http://superset/explore/?form_data_key=canonical",
+        ) as generate_link,
+        patch.object(
+            update_chart_preview_module, "generate_preview_from_form_data"
+        ) as generate_preview,
+        patch.object(
+            update_chart_preview_module, "analyze_chart_capabilities", return_value=None
+        ),
+        patch.object(
+            update_chart_preview_module, "analyze_chart_semantics", return_value=None
+        ),
+    ):
+        validate.return_value = SimpleNamespace(success=True)
+        generate_preview.return_value = VegaLitePreview(
+            specification={"mark": "bar"},
+            data_url=None,
+            supports_streaming=False,
+        )
+        result = update_chart_preview_module.update_chart_preview(
+            request=request, ctx=Mock()
+        )
+
+    assert result["success"] is True
+    form_data = generate_link.call_args.args[1]
+    assert form_data["start_time"] == "Start_Time"
+    assert form_data["end_time"] == "End_Time"
+    assert form_data["y_axis"] == "Task"
+    assert form_data["series"] == "Owner"
+    assert form_data["tooltip_columns"] == ["Project"]
+    assert form_data["tooltip_metrics"] == ["Completion"]
+    assert form_data["order_by_cols"] == ['["Priority", false]']
+    assert form_data["adhoc_filters"][0]["subject"] == "Owner"
+    temporal = [
+        filter_
+        for filter_ in form_data["adhoc_filters"]
+        if filter_["operator"] == "TEMPORAL_RANGE"
+    ]
+    assert temporal == [
+        {
+            "clause": "WHERE",
+            "expressionType": "SIMPLE",
+            "subject": "End_Time",
+            "operator": "TEMPORAL_RANGE",
+            "comparator": "Last 7 days",
+        }
+    ]
+    if generate_query_preview:
+        preview_form_data = generate_preview.call_args.kwargs["form_data"]
+        assert preview_form_data == form_data
+    else:
+        generate_preview.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    (
+        "overrides",
+        "expected_subject",
+        "expected_range",
+        "keeps_unrelated",
+        "expected_series",
+        "expected_subcategories",
+    ),
+    [
+        (
+            {"temporal_column": "end_time", "time_range": "Last 7 days"},
+            "End_Time",
+            "Last 7 days",
+            True,
+            "Owner",
+            True,
+        ),
+        (
+            {"temporal_column": "start_time", "time_range": "Last 7 days"},
+            "Start_Time",
+            "Last 7 days",
+            True,
+            "Owner",
+            True,
+        ),
+        ({}, "Start_Time", "Last 30 days", True, "Owner", True),
+        ({"filters": []}, "Start_Time", "No filter", False, "Owner", True),
+        (
+            {"subcategories": False},
+            "Start_Time",
+            "Last 30 days",
+            True,
+            "Owner",
+            False,
+        ),
+        ({"series": None}, "Start_Time", "Last 30 days", True, None, False),
+    ],
+)
+def test_cached_update_chart_preview_preserves_or_replaces_temporal_binding(
+    overrides: dict[str, object],
+    expected_subject: str,
+    expected_range: str,
+    keeps_unrelated: bool,
+    expected_series: str | None,
+    expected_subcategories: bool,
+) -> None:
+    columns = [
+        SimpleNamespace(
+            column_name=column["name"],
+            type=column["type"],
+            is_temporal=column["is_temporal"],
+            is_numeric=False,
+        )
+        for column in _dataset_context().available_columns
+    ]
+    dataset = SimpleNamespace(
+        id=1,
+        table_name="tasks",
+        schema=None,
+        columns=columns,
+        metrics=[],
+        database=SimpleNamespace(database_name="main", db_engine_spec=None),
+    )
+    old_binding = {
+        "clause": "WHERE",
+        "comparator": "Last 30 days",
+        "expressionType": "SIMPLE",
+        "operator": "TEMPORAL_RANGE",
+        "subject": "Start_Time",
+    }
+    unrelated = {
+        "clause": "WHERE",
+        "comparator": "MCP",
+        "expressionType": "SIMPLE",
+        "operator": "==",
+        "subject": "Project",
+    }
+    previous = {
+        "viz_type": "gantt_chart",
+        "adhoc_filters": [old_binding, unrelated],
+        "_mcp_dashboard_time_filter_subject": "Start_Time",
+        "series": "Owner",
+        "subcategories": True,
+    }
+    request = UpdateChartPreviewRequest(
+        form_data_key="previous",
+        dataset_id=1,
+        config=_config(**overrides),
+        generate_preview=False,
+    )
+
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            update_chart_preview_module, "_find_dataset", return_value=dataset
+        ),
+        patch.object(
+            update_chart_preview_module,
+            "_get_previous_form_data",
+            return_value=previous,
+        ),
+        patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+        patch.object(
+            update_chart_preview_module, "has_dataset_access", return_value=True
+        ),
+        patch.object(update_chart_preview_module, "validate_and_compile") as validate,
+        patch.object(
+            update_chart_preview_module,
+            "generate_explore_link",
+            return_value="http://superset/explore/?form_data_key=current",
+        ) as generate_link,
+        patch.object(
+            update_chart_preview_module, "analyze_chart_capabilities", return_value=None
+        ),
+        patch.object(
+            update_chart_preview_module, "analyze_chart_semantics", return_value=None
+        ),
+    ):
+        validate.return_value = SimpleNamespace(success=True)
+        result = update_chart_preview_module.update_chart_preview(
+            request=request, ctx=Mock()
+        )
+
+    assert result["success"] is True
+    form_data = generate_link.call_args.args[1]
+    temporal = [
+        filter_
+        for filter_ in form_data["adhoc_filters"]
+        if filter_["operator"] == "TEMPORAL_RANGE"
+    ]
+    assert len(temporal) == 1
+    assert temporal[0]["subject"] == expected_subject
+    assert temporal[0]["comparator"] == expected_range
+    if {"temporal_column", "time_range", "filters"} & overrides.keys():
+        assert form_data["_mcp_dashboard_time_filter_subject"] == expected_subject
+    else:
+        assert "_mcp_dashboard_time_filter_subject" not in form_data
+    assert (unrelated in form_data["adhoc_filters"]) is keeps_unrelated
+    assert form_data.get("series") == expected_series
+    assert form_data.get("subcategories", False) is expected_subcategories
+    assert not form_data.get("subcategories") or form_data.get("series")
+    GanttChartConfig.model_validate(form_data)
+
+
+def test_temporal_dataset_semantics() -> None:
+    from superset.mcp_service.chart import registry
+
+    plugin = registry.get("gantt")
+    assert plugin is not None
+    config = _config()
+    context = _dataset_context()
+    next(
+        column for column in context.available_columns if column["name"] == "Start_Time"
+    )["is_temporal"] = False
+    with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+        error = plugin.post_map_validate(config, {}, dataset_id=1)
+    assert error is not None
+    assert error.error_code == "NON_TEMPORAL_GANTT_TIME_COLUMN"
+
+
+def test_unsaved_gantt_vega_preview_representative_empty_and_invalid_data() -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+        "series": "owner",
+        "subcategories": True,
+        "tooltip_columns": ["project"],
+    }
+    data = [
+        {
+            "start_time": "2026-01-01T00:00:00",
+            "end_time": "2026-01-03T00:00:00",
+            "task": "Build",
+            "owner": "Amin",
+            "project": "MCP",
+        }
+    ]
+    preview = _generate_gantt_vega_lite_preview(data, form_data)
+    assert isinstance(preview, VegaLitePreview)
+    assert preview.specification["mark"]["type"] == "bar"
+    assert preview.specification["encoding"]["x2"] == {"field": "end_time"}
+    assert preview.specification["encoding"]["yOffset"]["field"] == "owner"
+
+    empty = _generate_gantt_vega_lite_preview([], form_data)
+    assert isinstance(empty, VegaLitePreview)
+    assert empty.specification["data"]["values"] == []
+
+    missing = _generate_gantt_vega_lite_preview([{"task": "Build"}], form_data)
+    assert isinstance(missing, ChartError)
+    assert missing.error_type == "InvalidGanttResult"
+
+
+@pytest.mark.parametrize(
+    ("bad_row", "message"),
+    [
+        (
+            {"start_time": value, "end_time": "2026-01-02", "task": "Build"},
+            "invalid start",
+        )
+        for value in (None, True, "not-a-date", float("inf"))
+    ]
+    + [
+        (
+            {"start_time": "2026-01-01", "end_time": value, "task": "Build"},
+            "invalid end",
+        )
+        for value in (None, False, "not-a-date", float("nan"), "2025-01-01")
+    ]
+    + [
+        (
+            {"start_time": "2026-01-01", "end_time": "2026-01-02", "task": value},
+            "invalid category",
+        )
+        for value in (None, True, "", "  ")
+    ],
+)
+def test_gantt_preview_skips_invalid_values_in_mixed_results(
+    bad_row: dict[str, object], message: str
+) -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+    }
+    valid = {
+        "start_time": 1767225600000,
+        "end_time": 1767312000000,
+        "task": "Plan",
+    }
+    result = _generate_gantt_vega_lite_preview([valid, bad_row], form_data)
+    assert isinstance(result, VegaLitePreview), message
+    assert result.specification["data"]["values"] == [valid]
+    invalid = _generate_gantt_vega_lite_preview([bad_row], form_data)
+    assert isinstance(invalid, ChartError)
+    assert invalid.error_type == "InvalidGanttResult"
+    assert "no renderable intervals" in invalid.error
+
+
+def test_gantt_preview_resolves_custom_column_and_metric_aliases() -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": {
+            "expressionType": "SQL",
+            "sqlExpression": "MIN(started_at)",
+            "label": "Start alias",
+        },
+        "end_time": {
+            "expressionType": "SQL",
+            "sqlExpression": "MAX(ended_at)",
+            "label": "End alias",
+        },
+        "y_axis": {
+            "expressionType": "SQL",
+            "sqlExpression": "task",
+            "label": "Task alias",
+        },
+        "tooltip_columns": [
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "owner",
+                "label": "Owner alias",
+            }
+        ],
+        "tooltip_metrics": [{"label": "Total cost"}],
+    }
+    data = [
+        {
+            "Start alias": "2026-01-01T00:00:00Z",
+            "End alias": "2026-01-02T00:00:00Z",
+            "Task alias": "Build",
+            "Owner alias": "Amin",
+            "Total cost": 42,
+        }
+    ]
+    preview = _generate_gantt_vega_lite_preview(data, form_data)
+    assert isinstance(preview, VegaLitePreview)
+    assert preview.specification["encoding"]["x"]["field"] == "Start alias"
+    assert {item["field"] for item in preview.specification["encoding"]["tooltip"]} >= {
+        "Owner alias",
+        "Total cost",
+    }
+
+    missing_later_alias = _generate_gantt_vega_lite_preview(
+        [
+            data[0],
+            {key: value for key, value in data[0].items() if key != "Owner alias"},
+        ],
+        form_data,
+    )
+    assert isinstance(missing_later_alias, ChartError)
+    assert "row 1" in missing_later_alias.error
+    assert "Owner alias" in missing_later_alias.error
+
+
+@pytest.mark.parametrize(
+    ("metric", "result_label"),
+    [
+        ("Completion", "Completion"),
+        (
+            {
+                "expressionType": "SIMPLE",
+                "aggregate": "SUM",
+                "column": {"column_name": "cost"},
+                "label": "Total cost",
+            },
+            "Total cost",
+        ),
+        (
+            {
+                "expressionType": "SIMPLE",
+                "aggregate": "AVG",
+                "column": {"columnName": "hours"},
+            },
+            "AVG(hours)",
+        ),
+        (
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "SUM(cost) / COUNT(*)",
+                "label": "Unit cost",
+            },
+            "Unit cost",
+        ),
+        (
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "SUM(cost) / COUNT(*)",
+            },
+            "SUM(cost) / COUNT(*)",
+        ),
+    ],
+)
+def test_saved_and_unsaved_preview_resolve_metric_result_labels(
+    metric: object, result_label: str
+) -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+        "tooltip_metrics": [metric],
+    }
+    data = [
+        {
+            "start_time": "2026-01-01",
+            "end_time": "2026-01-02",
+            "task": "Build",
+            result_label: 42,
+        }
+    ]
+
+    unsaved = _generate_gantt_vega_lite_preview(data, form_data)
+    assert isinstance(unsaved, VegaLitePreview)
+    assert result_label in {
+        item["field"] for item in unsaved.specification["encoding"]["tooltip"]
+    }
+
+    chart = SimpleNamespace(
+        id=1,
+        slice_name="Schedule",
+        viz_type="gantt_chart",
+        params=__import__("json").dumps(form_data),
+    )
+    strategy = VegaLitePreviewStrategy(
+        chart,
+        GetChartPreviewRequest(identifier=1, format="vega_lite"),
+    )
+    saved = strategy._create_vega_lite_spec(data)
+    assert result_label in {item["field"] for item in saved["encoding"]["tooltip"]}
+
+
+@pytest.mark.parametrize(
+    "metric",
+    [
+        {},
+        {"expressionType": "SIMPLE", "aggregate": "SUM", "column": {}},
+        {"expressionType": "SIMPLE", "aggregate": 1, "column": {}},
+        {"expressionType": "SQL", "sqlExpression": ""},
+        {"expressionType": "SQL", "sqlExpression": 123},
+        {"expressionType": "UNKNOWN", "sqlExpression": "SUM(cost)"},
+    ],
+)
+def test_gantt_preview_rejects_malformed_metric_objects(metric: object) -> None:
+    result = _generate_gantt_vega_lite_preview(
+        [
+            {
+                "start_time": "2026-01-01",
+                "end_time": "2026-01-02",
+                "task": "Build",
+            }
+        ],
+        {
+            "viz_type": "gantt_chart",
+            "start_time": "start_time",
+            "end_time": "end_time",
+            "y_axis": "task",
+            "tooltip_metrics": [metric],
+        },
+    )
+
+    assert isinstance(result, ChartError)
+    assert result.error_type == "InvalidGanttFormData"
+
+
+def test_saved_gantt_vega_preview_supports_valid_empty_result() -> None:
+    form_data = {
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+    }
+    chart = SimpleNamespace(
+        id=1,
+        slice_name="Schedule",
+        viz_type="gantt_chart",
+        params=__import__("json").dumps(form_data),
+    )
+    request = GetChartPreviewRequest(
+        identifier=1,
+        format="vega_lite",
+        width=640,
+        height=320,
+    )
+    strategy = VegaLitePreviewStrategy(chart, request)
+    spec = strategy._create_vega_lite_spec([])
+    assert spec["data"]["values"] == []
+    assert spec["mark"]["type"] == "bar"
+    assert spec["encoding"]["x"]["field"] == "start_time"
+
+    representative = strategy._create_vega_lite_spec(
+        [
+            {
+                "start_time": "2026-01-01T00:00:00",
+                "end_time": "2026-01-02T00:00:00",
+                "task": "Review",
+            }
+        ]
+    )
+    assert representative["encoding"]["x2"] == {"field": "end_time"}
+    with pytest.raises(ValueError, match="missing required output fields: end_time"):
+        strategy._create_vega_lite_spec(
+            [{"start_time": "2026-01-01T00:00:00", "task": "Review"}]
+        )
+
+
+def test_saved_and_unsaved_gantt_preview_return_the_same_structured_error() -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+    }
+    data = [
+        {
+            "start_time": "2026-01-03",
+            "end_time": "2026-01-02",
+            "task": "Review",
+        }
+    ]
+    unsaved = _generate_gantt_vega_lite_preview(data, form_data)
+    assert isinstance(unsaved, ChartError)
+
+    chart = SimpleNamespace(
+        id=1,
+        slice_name="Schedule",
+        viz_type="gantt_chart",
+        params=__import__("json").dumps(form_data),
+    )
+    strategy = VegaLitePreviewStrategy(
+        chart,
+        GetChartPreviewRequest(identifier=1, format="vega_lite"),
+    )
+    with (
+        patch(
+            "superset.mcp_service.chart.tool.get_chart_preview."
+            "build_query_context_from_form_data",
+            return_value=object(),
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+        patch.object(strategy, "_authorize_guest_query"),
+    ):
+        command.return_value.run.return_value = {"queries": [{"data": data}]}
+        saved = strategy.generate()
+
+    assert isinstance(saved, ChartError)
+    assert saved.error_type == unsaved.error_type
+    assert saved.error == unsaved.error
+
+
+@pytest.mark.parametrize(
+    "query_payload",
+    [
+        {"queries": [{"error": "empty query failure", "data": []}]},
+        {
+            "queries": [
+                {
+                    "error": "nonempty query failure",
+                    "data": [
+                        {
+                            "start_time": "2026-01-01",
+                            "end_time": "2026-01-02",
+                            "task": "Build",
+                        }
+                    ],
+                }
+            ]
+        },
+        {"error": "top-level failure", "queries": [{"data": []}]},
+        {"errors": ["top-level failure"], "queries": [{"data": []}]},
+        {
+            "queries": [
+                {"data": []},
+                {"status": "failed", "message": "second query failure", "data": []},
+            ]
+        },
+    ],
+)
+def test_saved_and_unsaved_gantt_previews_fail_on_embedded_query_errors(
+    query_payload: dict[str, object],
+) -> None:
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+    }
+    chart = SimpleNamespace(
+        id=1,
+        slice_name="Schedule",
+        viz_type="gantt_chart",
+        datasource_id=1,
+        datasource_type="table",
+        params=__import__("json").dumps(form_data),
+    )
+    strategy = VegaLitePreviewStrategy(
+        chart,
+        GetChartPreviewRequest(identifier=1, format="vega_lite"),
+    )
+    with (
+        patch(
+            "superset.mcp_service.chart.tool.get_chart_preview."
+            "build_query_context_from_form_data",
+            return_value=object(),
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+        patch.object(strategy, "_authorize_guest_query"),
+    ):
+        command.return_value.run.return_value = query_payload
+        saved = strategy.generate()
+
+    with (
+        patch("superset.extensions.db.session.get", return_value=object()),
+        patch(
+            "superset.mcp_service.chart.chart_helpers."
+            "build_query_context_from_form_data",
+            return_value=object(),
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+    ):
+        command.return_value.run.return_value = query_payload
+        unsaved = generate_preview_from_form_data(form_data, 1, "vega_lite")
+
+    assert isinstance(saved, ChartError)
+    assert saved.error_type == "QueryError"
+    assert isinstance(unsaved, ChartError)
+    assert unsaved.error_type == "QueryError"
+
+
+def test_valid_empty_query_result_stays_a_successful_empty_gantt_preview() -> None:
+    payload = {"queries": [{"status": "success", "data": []}]}
+    assert query_result_failure(payload) is None
+    form_data = {
+        "viz_type": "gantt_chart",
+        "start_time": "start_time",
+        "end_time": "end_time",
+        "y_axis": "task",
+    }
+    with (
+        patch("superset.extensions.db.session.get", return_value=object()),
+        patch(
+            "superset.mcp_service.chart.chart_helpers."
+            "build_query_context_from_form_data",
+            return_value=object(),
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+    ):
+        command.return_value.run.return_value = payload
+        preview = generate_preview_from_form_data(form_data, 1, "vega_lite")
+
+    assert isinstance(preview, VegaLitePreview)
+    assert preview.specification["data"]["values"] == []
+
+    chart = SimpleNamespace(
+        id=1,
+        slice_name="Schedule",
+        viz_type="gantt_chart",
+        datasource_id=1,
+        datasource_type="table",
+        params=__import__("json").dumps(form_data),
+    )
+    strategy = VegaLitePreviewStrategy(
+        chart, GetChartPreviewRequest(identifier=1, format="vega_lite")
+    )
+    with (
+        patch(
+            "superset.mcp_service.chart.tool.get_chart_preview."
+            "build_query_context_from_form_data",
+            return_value=object(),
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+        patch.object(strategy, "_authorize_guest_query"),
+    ):
+        command.return_value.run.return_value = payload
+        saved_preview = strategy.generate()
+
+    assert isinstance(saved_preview, VegaLitePreview)
+    assert saved_preview.specification["data"]["values"] == []
+
+
+def test_query_failure_detection_applies_without_changing_valid_table_preview() -> None:
+    failure = query_result_failure(
+        {"queries": [{"status": "timed_out", "message": "timeout", "data": []}]}
+    )
+    assert isinstance(failure, ChartError)
+    assert failure.error_type == "QueryError"
+
+    with (
+        patch("superset.extensions.db.session.get", return_value=object()),
+        patch(
+            "superset.mcp_service.chart.chart_helpers."
+            "build_query_context_from_form_data",
+            return_value=object(),
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+    ):
+        command.return_value.run.return_value = {"queries": [{"data": []}]}
+        preview = generate_preview_from_form_data(
+            {"viz_type": "table", "all_columns": ["task"]}, 1, "table"
+        )
+
+    assert isinstance(preview, TablePreview)
+
+
+def test_saved_update_and_preview_first_paths_preserve_native_gantt_state() -> None:
+    existing = _native_example()
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=None,
+        slice_name="Gantt",
+        params=__import__("json").dumps(existing),
+    )
+    config = _config(series={"name": "priority"})
+    request = UpdateChartRequest(identifier=1495, config=config)
+
+    payload = _build_update_payload(request, chart, parsed_config=config)
+    assert isinstance(payload, dict)
+    saved_form_data = __import__("json").loads(payload["params"])
+    assert saved_form_data["viz_type"] == "gantt_chart"
+    assert saved_form_data["legendOrientation"] == "right"
+    assert saved_form_data["legendMargin"] == 100
+    assert saved_form_data["x_axis_time_bounds"] == ["08:00:00", "19:00:00"]
+    assert payload["query_context"] is None
+
+    preview_form_data = _build_preview_form_data(request, chart, parsed_config=config)
+    assert isinstance(preview_form_data, dict)
+    assert preview_form_data["legendOrientation"] == "right"
+    assert preview_form_data["zoomable"] is False
+    assert preview_form_data["slice_id"] == 1495
+
+
+@pytest.mark.parametrize(
+    ("overrides", "expected_series", "expected_subcategories"),
+    [
+        ({}, "owner", True),
+        ({"subcategories": False}, "owner", False),
+        ({"series": {"name": "project"}}, "project", True),
+        ({"series": None}, None, False),
+        ({"series": None, "subcategories": False}, None, False),
+    ],
+)
+def test_saved_and_cached_update_merges_keep_gantt_series_dependency_coherent(
+    overrides: dict[str, object],
+    expected_series: str | None,
+    expected_subcategories: bool,
+) -> None:
+    existing = {
+        **_native_example(),
+        "series": "owner",
+        "subcategories": True,
+    }
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=None,
+        slice_name="Gantt",
+        params=__import__("json").dumps(existing),
+    )
+    config = _config(**overrides)
+    request = UpdateChartRequest(identifier=1495, config=config)
+
+    payload = _build_update_payload(request, chart, parsed_config=config)
+    cached_preview = _build_preview_form_data(request, chart, parsed_config=config)
+
+    assert isinstance(payload, dict)
+    assert isinstance(cached_preview, dict)
+    persisted = __import__("json").loads(payload["params"])
+    assert persisted.get("series") == expected_series
+    assert persisted.get("subcategories", False) is expected_subcategories
+    assert cached_preview.get("series") == expected_series
+    assert cached_preview.get("subcategories", False) is expected_subcategories
+    assert not persisted.get("subcategories") or persisted.get("series")
+    round_trip = GanttChartConfig.model_validate(persisted)
+    assert round_trip.subcategories is expected_subcategories
+    assert (round_trip.series.name if round_trip.series else None) == expected_series
+
+
+def test_merge_repairs_stale_subcategories_without_series() -> None:
+    replacement = map_gantt_config(_config())
+    merge_gantt_ui_config(
+        {"viz_type": "gantt_chart", "subcategories": True}, replacement
+    )
+
+    assert replacement.get("subcategories", False) is False
+    assert not replacement.get("series")
+    GanttChartConfig.model_validate(replacement)
+
+
+@pytest.mark.parametrize("generate_preview", [True, False], ids=["preview", "save"])
+@pytest.mark.parametrize(
+    "category",
+    ["Owner", "owner", "OwNeR"],
+    ids=["exact", "casefold", "canonical_alias"],
+)
+@pytest.mark.asyncio
+async def test_update_chart_rejects_preserved_series_colliding_with_new_category(
+    generate_preview: bool,
+    category: str,
+) -> None:
+    """Preview and save reject conflicts introduced by native-state merging."""
+    existing = {
+        **_native_example(),
+        "y_axis": "Task",
+        "series": "Owner",
+        "subcategories": True,
+    }
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=1,
+        datasource=object(),
+        slice_name="Gantt",
+        params=__import__("json").dumps(existing),
+        uuid="chart-uuid",
+        viz_type="gantt_chart",
+    )
+    request = UpdateChartRequest(
+        identifier=1495,
+        config=_config(category={"name": category}),
+        generate_preview=generate_preview,
+        preview_formats=[],
+    )
+
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            update_chart_module, "find_chart_by_identifier", return_value=chart
+        ),
+        patch(
+            "superset.mcp_service.auth.check_chart_data_access",
+            return_value=SimpleNamespace(is_valid=True, error=None),
+        ),
+        patch.object(
+            DatasetValidator,
+            "_get_dataset_context",
+            return_value=_dataset_context(),
+        ),
+        patch("superset.commands.chart.update.UpdateChartCommand") as command,
+        patch.object(update_chart_module, "_create_preview_url") as create_preview,
+    ):
+        result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+    assert result.success is False
+    assert result.error is not None
+    assert result.error.error_type == "ValidationError"
+    assert (
+        "series and category must reference different columns" in result.error.details
+    )
+    command.assert_not_called()
+    create_preview.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    "category",
+    ["Owner", "owner", "OwNeR"],
+    ids=["exact", "casefold", "canonical_alias"],
+)
+def test_update_chart_preview_rejects_cached_series_colliding_with_new_category(
+    category: str,
+) -> None:
+    """Standalone cached previews apply the same final-state semantic gate."""
+    context = _dataset_context()
+    columns = [
+        SimpleNamespace(
+            column_name=column["name"],
+            type=column["type"],
+            is_temporal=column["is_temporal"],
+            is_numeric=False,
+        )
+        for column in context.available_columns
+    ]
+    dataset = SimpleNamespace(
+        id=1,
+        table_name=context.table_name,
+        schema=None,
+        columns=columns,
+        metrics=[],
+        database=SimpleNamespace(database_name="main", db_engine_spec=None),
+    )
+    request = UpdateChartPreviewRequest(
+        form_data_key="previous",
+        dataset_id=1,
+        config=_config(category={"name": category}),
+        generate_preview=False,
+        preview_formats=[],
+    )
+    previous = {
+        "viz_type": "gantt_chart",
+        "start_time": "Start_Time",
+        "end_time": "End_Time",
+        "y_axis": "Task",
+        "series": "Owner",
+        "subcategories": True,
+    }
+
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1),
+        ),
+        patch.object(
+            update_chart_preview_module, "_find_dataset", return_value=dataset
+        ),
+        patch.object(
+            update_chart_preview_module,
+            "_get_previous_form_data",
+            return_value=previous,
+        ),
+        patch.object(update_chart_preview_module, "generate_explore_link") as link,
+    ):
+        result = update_chart_preview_module.update_chart_preview(request, ctx=Mock())
+
+    assert result["success"] is False
+    assert result["error"]["error_type"] == "gantt_semantic_validation_error"
+    assert (
+        "series and category must reference different columns"
+        in result["error"]["details"]
+    )
+    link.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_real_update_preserves_omitted_and_overrides_explicit_defaults() -> None:
+    saved_presentation = {
+        "color_scheme": "legacyColors",
+        "show_legend": False,
+        "legendOrientation": "right",
+        "legendType": "plain",
+        "legendMargin": 91,
+        "legendSort": "desc",
+        "zoomable": True,
+        "subcategories": True,
+        "show_extra_controls": True,
+        "x_axis_time_bounds": ["08:00:00", "19:00:00"],
+        "x_axis_time_format": "%Y-%m-%d",
+        "tooltipTimeFormat": "%H:%M",
+        "tooltipValuesFormat": ",.2f",
+        "x_axis_title": "Saved X title",
+        "x_axis_title_margin": 23,
+        "y_axis_title": "Saved Y title",
+        "y_axis_title_margin": 37,
+    }
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=1,
+        datasource=object(),
+        slice_name="Gantt",
+        params=__import__("json").dumps(
+            {
+                **_native_example(),
+                **saved_presentation,
+                "series": "priority",
+                "y_axis_title_position": "start",
+            }
+        ),
+        uuid="chart-uuid",
+        viz_type="gantt_chart",
+    )
+    updated_chart = SimpleNamespace(
+        id=1495,
+        slice_name="Gantt",
+        uuid="chart-uuid",
+        viz_type="gantt_chart",
+    )
+
+    async def run_update(config: GanttChartConfig) -> dict[str, object]:
+        request = UpdateChartRequest(
+            identifier=1495,
+            config=config,
+            generate_preview=False,
+            preview_formats=[],
+        )
+        with (
+            patch(
+                "superset.mcp_service.auth.get_user_from_request",
+                return_value=Mock(id=1),
+            ),
+            patch.object(
+                update_chart_module, "find_chart_by_identifier", return_value=chart
+            ),
+            patch(
+                "superset.mcp_service.auth.check_chart_data_access",
+                return_value=SimpleNamespace(is_valid=True, error=None),
+            ),
+            patch.object(
+                DatasetValidator,
+                "_get_dataset_context",
+                return_value=_dataset_context(),
+            ),
+            patch.object(
+                update_chart_module,
+                "_validate_update_against_dataset",
+                return_value=None,
+            ),
+            patch("superset.commands.chart.update.UpdateChartCommand") as command,
+            patch.object(
+                update_chart_module, "analyze_chart_capabilities", return_value=None
+            ),
+            patch.object(
+                update_chart_module, "analyze_chart_semantics", return_value=None
+            ),
+            patch.object(
+                update_chart_module,
+                "get_superset_base_url",
+                return_value="http://superset",
+            ),
+        ):
+            command.return_value.run.return_value = updated_chart
+            result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+        assert result.success is True
+        payload = command.call_args.args[1]
+        return __import__("json").loads(payload["params"])
+
+    omitted = await run_update(
+        _config(
+            series={"name": "priority"},
+            tooltip_columns=[{"name": "project"}],
+            order_by=[{"column": "priority", "ascending": True}],
+        )
+    )
+    assert {key: omitted[key] for key in saved_presentation} == saved_presentation
+    assert "y_axis_title_position" not in omitted
+    assert omitted["start_time"] == "Start_Time"
+    assert omitted["end_time"] == "End_Time"
+    assert omitted["y_axis"] == "Task"
+    assert omitted["series"] == "Priority"
+    assert omitted["tooltip_columns"] == ["Project"]
+    assert omitted["order_by_cols"] == ['["Priority", true]']
+
+    explicit_defaults = {
+        "color_scheme": None,
+        "show_legend": True,
+        "legend_orientation": "top",
+        "legend_type": "scroll",
+        "legend_margin": None,
+        "legend_sort": None,
+        "zoomable": False,
+        "subcategories": False,
+        "show_extra_controls": False,
+        "x_axis_time_bounds": None,
+        "x_axis_time_format": "smart_date",
+        "tooltip_time_format": "smart_date",
+        "tooltip_values_format": "SMART_NUMBER",
+        "x_axis_title": None,
+        "x_axis_title_margin": None,
+        "y_axis_title": None,
+        "y_axis_title_margin": None,
+    }
+    overridden = await run_update(
+        _config(series={"name": "priority"}, **explicit_defaults)
+    )
+    expected_native_defaults = {
+        "color_scheme": None,
+        "show_legend": True,
+        "legendOrientation": "top",
+        "legendType": "scroll",
+        "legendMargin": None,
+        "legendSort": None,
+        "zoomable": False,
+        "subcategories": False,
+        "show_extra_controls": False,
+        "x_axis_time_bounds": None,
+        "x_axis_time_format": "smart_date",
+        "tooltipTimeFormat": "smart_date",
+        "tooltipValuesFormat": "SMART_NUMBER",
+        "x_axis_title": None,
+        "x_axis_title_margin": None,
+        "y_axis_title": None,
+        "y_axis_title_margin": None,
+    }
+    assert {
+        key: overridden[key] for key in expected_native_defaults
+    } == expected_native_defaults
+
+    omitted_dependency = await run_update(_config())
+    assert omitted_dependency["series"] == "priority"
+    assert omitted_dependency["subcategories"] is True
+    GanttChartConfig.model_validate(omitted_dependency)
+
+    removed_dependency = await run_update(_config(series=None))
+    assert removed_dependency.get("series") is None
+    assert removed_dependency["subcategories"] is False
+    GanttChartConfig.model_validate(removed_dependency)
+
+
+def test_gantt_rejects_false_success_y_axis_title_position() -> None:
+    schema = _get_chart_type_schema_impl("gantt")["schema"]
+    assert "y_axis_title_position" not in schema["properties"]
+    with pytest.raises(ValidationError, match="unsupported by Gantt"):
+        GanttChartConfig.model_validate(
+            {**_config().model_dump(), "y_axis_title_position": "start"}
+        )
+
+
+@pytest.mark.parametrize(
+    "overrides",
+    [
+        {},
+        {"filters": []},
+        {"filters": [{"column": "owner", "op": "=", "value": "Amin"}]},
+        {"temporal_column": "end_time", "time_range": "Last 7 days"},
+    ],
+)
+def test_gantt_update_preview_matches_would_be_persisted_state(
+    overrides: dict[str, object],
+) -> None:
+    existing = {
+        **_native_example(),
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "project",
+                "operator": "==",
+                "comparator": "Legacy",
+            }
+        ],
+    }
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=None,
+        slice_name="Gantt",
+        params=__import__("json").dumps(existing),
+    )
+    config = _config(**overrides)
+    request = UpdateChartRequest(identifier=1495, config=config)
+
+    payload = _build_update_payload(request, chart, parsed_config=config)
+    preview = _build_preview_form_data(request, chart, parsed_config=config)
+    assert isinstance(payload, dict)
+    assert isinstance(preview, dict)
+    assert request.generate_preview is True
+    assert preview["slice_name"] == "Gantt"
+    assert isinstance(validate_gantt_form_data(preview), GanttChartConfig)
+    persisted = __import__("json").loads(payload["params"])
+    preview_state = {
+        key: value
+        for key, value in preview.items()
+        if key not in {"datasource", "slice_id", "slice_name"}
+    }
+    assert preview_state == persisted
+
+    previous_filters = existing["adhoc_filters"]
+    assert isinstance(previous_filters, list)
+    assert (previous_filters[0] in persisted["adhoc_filters"]) is (
+        "filters" not in overrides
+    )
+
+    marker = persisted["_mcp_dashboard_time_filter_subject"]
+    temporal_filters = [
+        filter_
+        for filter_ in persisted["adhoc_filters"]
+        if filter_["operator"] == "TEMPORAL_RANGE"
+    ]
+    assert len(temporal_filters) == 1
+    assert temporal_filters[0]["subject"] == marker
+    expected_range = overrides.get("time_range", "No filter")
+    assert temporal_filters[0]["comparator"] == expected_range
+
+
+def test_registry_schema_validation_and_presentation_merge_wiring() -> None:
+    from superset.mcp_service.chart import registry
+
+    plugin = registry.get("gantt")
+    assert plugin is not None
+    assert plugin.resolve_viz_type(None) == "gantt_chart"
+    assert registry.display_name_for_viz_type("gantt_chart") == "Gantt Chart"
+    valid, parsed, error = SchemaValidator.validate_request(
+        {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "gantt",
+                "start_time": {"name": "start_time"},
+                "end_time": {"name": "end_time"},
+                "category": {"name": "task"},
+            },
+        }
+    )
+    assert valid is True
+    assert parsed is not None
+    assert error is None
+
+    previous = {
+        "viz_type": "gantt_chart",
+        "legendOrientation": "right",
+        "legendMargin": 100,
+        "zoomable": True,
+        "x_axis_time_bounds": ["08:00:00", "19:00:00"],
+    }
+    replacement = map_gantt_config(_config())
+    merge_gantt_ui_config(previous, replacement)
+    assert replacement["legendOrientation"] == "right"
+    assert replacement["legendMargin"] == 100
+    assert replacement["zoomable"] is True
+    assert replacement["x_axis_time_bounds"] == ["08:00:00", "19:00:00"]
+
+
+@pytest.mark.parametrize("field", ["start_time", "end_time", "y_axis", "series"])
+def test_gantt_preview_accepts_native_physical_column_objects(field: str) -> None:
+    form_data: dict[str, object] = {
+        "viz_type": "gantt_chart",
+        "start_time": "start",
+        "end_time": "end",
+        "y_axis": "task",
+        "series": "owner",
+        "tooltip_columns": [{"column_name": "project"}],
+    }
+    form_data[field] = {"column_name": form_data[field]}
+    row = {"start": 1, "end": 2, "task": "Build", "owner": "Amin", "project": "MCP"}
+    preview = _generate_gantt_vega_lite_preview([row], form_data)
+    assert isinstance(preview, VegaLitePreview)
+    assert preview.specification["data"]["values"] == [row]
+
+
+def test_gantt_preview_bounds_renderable_rows_and_projects_used_fields() -> None:
+    form_data = {
+        "start_time": "start",
+        "end_time": "end",
+        "y_axis": "task",
+    }
+    invalid = {"start": None, "end": 2, "task": "Open"}
+    valid = {"start": 1, "end": 2, "task": "Build", "unused": "large payload"}
+    preview = _generate_gantt_vega_lite_preview(
+        [invalid] * 1001 + [valid] * 1001, form_data
+    )
+    assert isinstance(preview, VegaLitePreview)
+    assert (
+        preview.specification["data"]["values"]
+        == [{"start": 1, "end": 2, "task": "Build"}] * 1000
+    )
+
+
+def test_gantt_saved_update_replaces_marked_binding_and_preserves_native_filters() -> (
+    None
+):
+    """A changed temporal subject must not discard unrelated saved predicates."""
+    existing = {
+        "viz_type": "gantt_chart",
+        "_mcp_dashboard_time_filter_subject": "start_time",
+        "adhoc_filters": [
+            {
+                "expressionType": "SIMPLE",
+                "clause": "WHERE",
+                "subject": "start_time",
+                "operator": "TEMPORAL_RANGE",
+                "comparator": "Last 30 days",
+            },
+            {
+                "expressionType": "SIMPLE",
+                "clause": "WHERE",
+                "subject": "owner",
+                "operator": "==",
+                "comparator": "Amin",
+            },
+        ],
+    }
+    config = _config(temporal_column="end_time", time_range="Last 7 days")
+    chart = SimpleNamespace(
+        id=1,
+        datasource_id=None,
+        slice_name="Gantt",
+        params=__import__("json").dumps(existing),
+    )
+    request = UpdateChartRequest(identifier=1, config=config)
+    payload = _build_update_payload(request, chart, parsed_config=config)
+    assert isinstance(payload, dict)
+    saved = __import__("json").loads(payload["params"])
+    assert existing["adhoc_filters"][1] in saved["adhoc_filters"]
+    temporal = [
+        item for item in saved["adhoc_filters"] if item["operator"] == "TEMPORAL_RANGE"
+    ]
+    assert len(temporal) == 1
+    assert temporal[0]["subject"] == "end_time"
+    assert temporal[0]["comparator"] == "Last 7 days"
+    GanttChartConfig.model_validate(saved)
+
+
+@pytest.mark.asyncio
+async def test_default_gantt_update_validates_named_preview_before_caching() -> None:
+    """Default updates validate the complete preview envelope, not stripped params."""
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=1,
+        datasource=SimpleNamespace(id=1),
+        slice_name="Saved schedule",
+        params=__import__("json").dumps(_native_example()),
+        uuid="chart-uuid",
+        viz_type="gantt_chart",
+    )
+    request = UpdateChartRequest(identifier=1495, config=_config())
+    assert request.generate_preview is True
+
+    with (
+        patch.object(
+            update_chart_module.event_logger,
+            "log_context",
+            side_effect=lambda **kwargs: nullcontext(),
+        ),
+        patch(
+            "superset.mcp_service.auth.get_user_from_request", return_value=Mock(id=1)
+        ),
+        patch.object(
+            update_chart_module, "find_chart_by_identifier", return_value=chart
+        ),
+        patch(
+            "superset.mcp_service.auth.check_chart_data_access",
+            return_value=SimpleNamespace(is_valid=True, error=None),
+        ),
+        patch.object(
+            DatasetValidator, "_get_dataset_context", return_value=_dataset_context()
+        ),
+        patch.object(
+            update_chart_module,
+            "validate_and_compile",
+            return_value=SimpleNamespace(success=True),
+        ) as compile_chart,
+        patch.object(
+            update_chart_module,
+            "_create_preview_url",
+            return_value=(
+                "http://superset/explore/?form_data_key=preview",
+                "preview",
+                [],
+            ),
+        ) as create_preview,
+        patch("superset.commands.chart.update.UpdateChartCommand") as command,
+    ):
+        result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+    assert result.success is True
+    validated_config, preview, dataset = compile_chart.call_args.args
+    assert isinstance(validated_config, GanttChartConfig)
+    assert preview["slice_name"] == "Saved schedule"
+    assert preview["slice_id"] == chart.id
+    assert preview["datasource"] == "1__table"
+    assert dataset is chart.datasource
+    assert create_preview.call_args.args[1] == preview
+    command.assert_not_called()
+
+
+def test_gantt_transport_name_does_not_allow_unknown_controls() -> None:
+    """Allow chart naming metadata without weakening visualization typo checks."""
+    form_data = {**map_gantt_config(_config()), "slice_name": "Saved schedule"}
+    with pytest.raises(
+        GanttSemanticNormalizationError, match="Unknown field 'zoomabl'"
+    ):
+        validate_gantt_form_data({**form_data, "zoomabl": True})
+
+
+def _gantt_partial_update_states(
+    existing: dict[str, object], config: GanttChartConfig, dataset_rebind: bool = False
+) -> list[dict[str, Any]]:
+    """Build saved-preview, persisted, and cached-preview shared merge states."""
+    chart = SimpleNamespace(
+        id=1495,
+        datasource_id=1,
+        slice_name="Saved schedule",
+        params=__import__("json").dumps(existing),
+    )
+    request = UpdateChartRequest(
+        identifier=1495, config=config, dataset_id=2 if dataset_rebind else None
+    )
+    with patch(
+        "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+        return_value=True,
+    ):
+        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)
+    persisted = __import__("json").loads(payload["params"])
+    cached = merge_chart_form_data(
+        existing, map_gantt_config(config), config, dataset_rebind=dataset_rebind
+    )
+    return [preview, persisted, cached]
+
+
+@pytest.mark.parametrize("explicit", [False, True])
+def test_gantt_partial_update_preserves_omitted_query_controls(explicit: bool) -> None:
+    """Omitted query controls survive; explicit empty/default values replace them."""
+    existing = map_gantt_config(
+        _config(
+            tooltip_columns=[{"name": "project"}],
+            tooltip_metrics=[{"name": "priority", "aggregate": "SUM"}],
+            order_by=[{"column": "priority", "ascending": False}],
+            row_limit=42,
+        )
+    )
+    config = (
+        _config(
+            zoomable=True,
+            tooltip_columns=[],
+            tooltip_metrics=[],
+            order_by=[],
+            row_limit=10000,
+        )
+        if explicit
+        else _config(zoomable=True)
+    )
+    for state in _gantt_partial_update_states(existing, config):
+        assert state["zoomable"] is True
+        for key in ("tooltip_columns", "tooltip_metrics", "order_by_cols", "row_limit"):
+            assert state[key] == (
+                map_gantt_config(config)[key] if explicit else existing[key]
+            )
+        assert isinstance(validate_gantt_form_data(state), GanttChartConfig)
+    assert existing["row_limit"] == 42
+
+
+def test_gantt_dataset_rebind_drops_saved_query_references() -> None:
+    """Changing datasets must not carry old series, filters, or tooltip/sort roles."""
+    existing = map_gantt_config(
+        _config(
+            series={"name": "old_series"},
+            subcategories=True,
+            filters=[{"column": "old_filter", "op": "=", "value": "old"}],
+            temporal_column="old_time",
+            time_range="Last month",
+            tooltip_columns=[{"name": "old_tooltip"}],
+            tooltip_metrics=[{"name": "old_metric", "saved_metric": True}],
+            order_by=[{"column": "old_sort", "ascending": True}],
+            row_limit=42,
+        )
+    )
+    config = _config()
+    for state in _gantt_partial_update_states(existing, config, dataset_rebind=True):
+        assert not state.get("series")
+        assert not state.get("subcategories")
+        assert state["tooltip_columns"] == []
+        assert state["tooltip_metrics"] == []
+        assert state["order_by_cols"] == []
+        assert state["row_limit"] == 10000
+        assert state["adhoc_filters"] == map_gantt_config(config)["adhoc_filters"]
+        assert "old_" not in __import__("json").dumps(state)
+        assert isinstance(validate_gantt_form_data(state), GanttChartConfig)
+
+
+@pytest.mark.parametrize(
+    ("overrides", "subject", "comparator"),
+    [
+        ({}, "end_time", "Last month"),
+        ({"time_range": "Last 7 days"}, "start_time", "Last 7 days"),
+        ({"temporal_column": "start_time"}, "start_time", "No filter"),
+        ({"filters": []}, "start_time", "No filter"),
+    ],
+)
+@pytest.mark.parametrize("keep_marker", [False, True])
+@pytest.mark.parametrize("previous_subject", ["start_time", "end_time"])
+def test_gantt_partial_update_reconciles_native_temporal_filter(
+    overrides: dict[str, object],
+    subject: str,
+    comparator: str,
+    keep_marker: bool,
+    previous_subject: str,
+) -> None:
+    """Keep an omitted native time predicate without adding a second binding."""
+    existing = map_gantt_config(
+        _config(
+            temporal_column=previous_subject,
+            time_range="Last month",
+            filters=[{"column": "project", "op": "=", "value": "Legacy"}],
+        )
+    )
+    if not keep_marker:
+        existing.pop("_mcp_dashboard_time_filter_subject")
+    for state in _gantt_partial_update_states(
+        existing, _config(zoomable=True, **overrides)
+    ):
+        adapted = validate_gantt_form_data(state)
+        assert isinstance(adapted, GanttChartConfig)
+        temporal = [
+            item
+            for item in state["adhoc_filters"]
+            if item["operator"] == "TEMPORAL_RANGE"
+        ]
+        assert len(temporal) == 1
+        assert temporal[0]["subject"] == (subject if overrides else previous_subject)
+        assert temporal[0]["comparator"] == comparator
+        if overrides:
+            assert state["_mcp_dashboard_time_filter_subject"] == subject
+        else:
+            assert "_mcp_dashboard_time_filter_subject" not in state
+            for repeated in _gantt_partial_update_states(
+                state, _config(zoomable=False)
+            ):
+                assert repeated["adhoc_filters"] == state["adhoc_filters"]
+        assert any(item["subject"] == "project" for item in state["adhoc_filters"]) is (
+            "filters" not in overrides
+        )
+
+
+@pytest.mark.parametrize("set_in_explore", [False, True])
+def test_gantt_partial_update_preserves_active_range_on_marked_subject(
+    set_in_explore: bool,
+) -> None:
+    """A marked start column retains both MCP-set and Explore-edited ranges."""
+    time_range = "2024-06-01 : 2024-07-01"
+    existing = map_gantt_config(
+        _config(time_range="No filter" if set_in_explore else time_range)
+    )
+    marker = existing["_mcp_dashboard_time_filter_subject"]
+    temporal = next(
+        item
+        for item in existing["adhoc_filters"]
+        if item["operator"] == "TEMPORAL_RANGE"
+    )
+    assert temporal["subject"] == marker == "start_time"
+    if set_in_explore:
+        temporal["comparator"] = time_range
+
+    for state in _gantt_partial_update_states(existing, _config(show_legend=False)):
+        assert state["show_legend"] is False
+        assert state["adhoc_filters"] == existing["adhoc_filters"]
+        assert isinstance(validate_gantt_form_data(state), GanttChartConfig)
+        for repeated in _gantt_partial_update_states(state, _config(show_legend=True)):
+            assert repeated["adhoc_filters"] == existing["adhoc_filters"]
+            assert isinstance(validate_gantt_form_data(repeated), GanttChartConfig)
+
+
+@pytest.mark.parametrize(
+    ("bounds", "expected"),
+    [
+        (["", "19:00:00"], (None, "19:00:00")),
+        (["08:00:00", ""], ("08:00:00", None)),
+        (["", ""], (None, None)),
+        ([None, ""], (None, None)),
+    ],
+)
+def test_gantt_partial_update_accepts_empty_native_time_bounds(
+    bounds: list[str | None], expected: tuple[str | None, str | None]
+) -> None:
+    """Explore's empty bound strings round-trip as unset values during updates."""
+    existing = {**map_gantt_config(_config()), "x_axis_time_bounds": bounds}
+    for state in _gantt_partial_update_states(existing, _config(zoomable=True)):
+        adapted = validate_gantt_form_data(state)
+        assert isinstance(adapted, GanttChartConfig)
+        assert adapted.x_axis_time_bounds == expected
+        assert map_gantt_config(adapted)["x_axis_time_bounds"] == list(expected)
+
+
+@pytest.mark.parametrize("overrides", [{}, {"time_range": "Last 7 days"}])
+def test_gantt_partial_update_rejects_multiple_native_temporal_predicates(
+    overrides: dict[str, object],
+) -> None:
+    """Reconciliation must not silently discard multiple user time predicates."""
+    existing = map_gantt_config(
+        _config(temporal_column="end_time", time_range="Last month")
+    )
+    existing.pop("_mcp_dashboard_time_filter_subject")
+    existing["adhoc_filters"].append(
+        {
+            "expressionType": "SIMPLE",
+            "clause": "WHERE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "other_time",
+            "comparator": "Last year",
+        }
+    )
+    states = _gantt_partial_update_states(existing, _config(**overrides))
+    for state in states:
+        with pytest.raises(
+            GanttSemanticNormalizationError, match="at most one TEMPORAL_RANGE"
+        ):
+            validate_gantt_form_data(state)
+
+
+@pytest.mark.parametrize("bound", [" ", "8am", "08:00", "25:00:00"])
+def test_gantt_partial_update_still_rejects_invalid_nonempty_time_bounds(
+    bound: str,
+) -> None:
+    """Only empty bound strings are unset; malformed times remain errors."""
+    existing = {**map_gantt_config(_config()), "x_axis_time_bounds": [bound, ""]}
+    with pytest.raises(GanttSemanticNormalizationError, match="HH:MM:SS"):
+        _gantt_partial_update_states(existing, _config())
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 41925b85a8f..63050c1378f 100644
--- a/tests/unit_tests/mcp_service/chart/test_preview_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_preview_utils.py
@@ -22,9 +22,12 @@ Tests for preview_utils query context column building.
 import ast
 import inspect
 from pathlib import Path
-from unittest.mock import Mock, patch
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
 
 from superset.mcp_service.chart import preview_utils
+from superset.mcp_service.chart.schemas import TablePreview
 
 
 def _imports_chart_data_command(node: ast.Import | ast.ImportFrom) -> bool:
@@ -69,7 +72,7 @@ class TestPreviewUtilsColumnBuilding:
     XY charts, and fall back to form_data["columns"] for table charts.
     """
 
-    def test_xy_chart_uses_x_axis_and_groupby(self):
+    def test_xy_chart_uses_x_axis_and_groupby(self) -> None:
         """Test XY chart form_data builds columns from x_axis + groupby."""
         form_data = {
             "x_axis": "territory",
@@ -77,44 +80,22 @@ class TestPreviewUtilsColumnBuilding:
             "metrics": [{"label": "SUM(sales)"}],
         }
 
-        x_axis_config = form_data.get("x_axis")
-        groupby_columns = form_data.get("groupby", [])
-        raw_columns = form_data.get("columns", [])
-
-        columns = (
-            raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
-        )
-        if x_axis_config and isinstance(x_axis_config, str):
-            if x_axis_config not in columns:
-                columns.insert(0, x_axis_config)
-        elif x_axis_config and isinstance(x_axis_config, dict):
-            col_name = x_axis_config.get("column_name")
-            if col_name and col_name not in columns:
-                columns.insert(0, col_name)
+        columns = preview_utils._build_query_columns(form_data)
 
         assert columns == ["territory", "year"]
 
-    def test_table_chart_uses_columns_field(self):
+    def test_table_chart_uses_columns_field(self) -> None:
         """Test table chart form_data uses 'columns' field directly."""
         form_data = {
             "columns": ["name", "region", "sales"],
             "metrics": [],
         }
 
-        x_axis_config = form_data.get("x_axis")
-        groupby_columns = form_data.get("groupby", [])
-        raw_columns = form_data.get("columns", [])
-
-        columns = (
-            raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
-        )
-        if x_axis_config and isinstance(x_axis_config, str):
-            if x_axis_config not in columns:
-                columns.insert(0, x_axis_config)
+        columns = preview_utils._build_query_columns(form_data)
 
         assert columns == ["name", "region", "sales"]
 
-    def test_xy_chart_x_axis_dict_format(self):
+    def test_xy_chart_x_axis_dict_format(self) -> None:
         """Test XY chart with x_axis as dict (column_name key)."""
         form_data = {
             "x_axis": {"column_name": "order_date"},
@@ -122,63 +103,32 @@ class TestPreviewUtilsColumnBuilding:
             "metrics": [{"label": "SUM(revenue)"}],
         }
 
-        x_axis_config = form_data.get("x_axis")
-        groupby_columns = form_data.get("groupby", [])
-        raw_columns = form_data.get("columns", [])
-
-        columns = (
-            raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
-        )
-        if x_axis_config and isinstance(x_axis_config, str):
-            if x_axis_config not in columns:
-                columns.insert(0, x_axis_config)
-        elif x_axis_config and isinstance(x_axis_config, dict):
-            col_name = x_axis_config.get("column_name")
-            if col_name and col_name not in columns:
-                columns.insert(0, col_name)
+        columns = preview_utils._build_query_columns(form_data)
 
         assert columns == ["order_date", "product_type"]
 
-    def test_no_x_axis_no_columns_uses_groupby(self):
+    def test_no_x_axis_no_columns_uses_groupby(self) -> None:
         """Test fallback to groupby when no x_axis and no columns."""
         form_data = {
             "groupby": ["category"],
             "metrics": [{"label": "COUNT(*)"}],
         }
 
-        x_axis_config = form_data.get("x_axis")
-        groupby_columns = form_data.get("groupby", [])
-        raw_columns = form_data.get("columns", [])
-
-        columns = (
-            raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
-        )
-        if x_axis_config and isinstance(x_axis_config, str):
-            if x_axis_config not in columns:
-                columns.insert(0, x_axis_config)
+        columns = preview_utils._build_query_columns(form_data)
 
         assert columns == ["category"]
 
-    def test_empty_form_data_returns_empty_columns(self):
+    def test_empty_form_data_returns_empty_columns(self) -> None:
         """Test empty form_data returns empty columns list."""
-        form_data: dict = {
+        form_data = {
             "metrics": [{"label": "COUNT(*)"}],
         }
 
-        x_axis_config = form_data.get("x_axis")
-        groupby_columns = form_data.get("groupby", [])
-        raw_columns = form_data.get("columns", [])
-
-        columns = (
-            raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
-        )
-        if x_axis_config and isinstance(x_axis_config, str):
-            if x_axis_config not in columns:
-                columns.insert(0, x_axis_config)
+        columns = preview_utils._build_query_columns(form_data)
 
         assert columns == []
 
-    def test_x_axis_not_duplicated_when_in_groupby(self):
+    def test_x_axis_not_duplicated_when_in_groupby(self) -> None:
         """Test x_axis is not added if already present in groupby."""
         form_data = {
             "x_axis": "territory",
@@ -186,16 +136,7 @@ class TestPreviewUtilsColumnBuilding:
             "metrics": [{"label": "SUM(sales)"}],
         }
 
-        x_axis_config = form_data.get("x_axis")
-        groupby_columns = form_data.get("groupby", [])
-        raw_columns = form_data.get("columns", [])
-
-        columns = (
-            raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
-        )
-        if x_axis_config and isinstance(x_axis_config, str):
-            if x_axis_config not in columns:
-                columns.insert(0, x_axis_config)
+        columns = preview_utils._build_query_columns(form_data)
 
         assert columns == ["territory", "year"]
 
@@ -212,6 +153,58 @@ def test_build_query_columns_empty_columns_key_keeps_groupby():
     ) == ["country"]
 
 
+@pytest.mark.parametrize("time_grain", [None, "P1D", "P1M"])
+def test_unsaved_big_number_preview_uses_temporal_query_contract(
+    time_grain: str | None,
+) -> None:
+    """The shared preview keeps raw temporal grouping, not grain bucketing."""
+    form_data = {
+        "viz_type": "big_number",
+        "x_axis": {"column_name": "recorded_at"},
+        "granularity_sqla": "event_time",
+        "time_grain_sqla": time_grain,
+        "metric": "count",
+        "show_trend_line": True,
+        "time_range": "Last week",
+        "adhoc_filters": [
+            {
+                "clause": "WHERE",
+                "expressionType": "SIMPLE",
+                "subject": "region",
+                "operator": "==",
+                "comparator": "EMEA",
+            }
+        ],
+    }
+    with (
+        patch(
+            "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+            return_value="base",
+        ),
+        patch("superset.extensions.db.session.get", return_value=object()),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand"
+        ) as command,
+        patch("superset.common.query_context_factory.QueryContextFactory") as factory,
+    ):
+        factory.return_value.create.return_value = MagicMock()
+        command.return_value.run.return_value = {
+            "queries": [{"status": "success", "data": []}]
+        }
+
+        result = preview_utils.generate_preview_from_form_data(form_data, 1, "table")
+
+    assert isinstance(result, TablePreview)
+    query = factory.return_value.create.call_args.kwargs["queries"][0]
+    assert query["columns"] == ["recorded_at"]
+    assert "granularity" not in query
+    assert "time_grain_sqla" not in query.get("extras", {})
+    assert query["metrics"] == ["count"]
+    assert query["time_range"] == "Last week"
+    assert query["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
+    assert query["row_limit"] == 100
+
+
 @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")
diff --git a/tests/unit_tests/mcp_service/chart/test_query_result.py b/tests/unit_tests/mcp_service/chart/test_query_result.py
new file mode 100644
index 00000000000..8352d902442
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_query_result.py
@@ -0,0 +1,71 @@
+# 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.
+
+from enum import Enum
+
+import pytest
+
+from superset.common.db_query_status import QueryStatus
+from superset.mcp_service.chart.query_result import query_result_failure
+
+
+class UppercaseStatus(Enum):
+    FAILED = "FAILED"
+
+
+@pytest.mark.parametrize(
+    "payload",
+    [
+        {"error": "top-level error", "queries": []},
+        {"error_message": "top-level error message", "queries": []},
+        {"status": "ERROR", "message": "top-level status failure"},
+        {"status": "timed out", "message": "top-level timeout"},
+        {"success": False, "message": "top-level unsuccessful payload"},
+        {"message": "standalone top-level failure"},
+        {"queries": [{"status": "Failed", "message": "query failed"}]},
+        {
+            "queries": [
+                {"status": "success", "data": [{"value": 1}]},
+                {"status": QueryStatus.FAILED, "error_message": "second failed"},
+            ]
+        },
+        {"queries": [{"status": UppercaseStatus.FAILED, "message": "enum failed"}]},
+    ],
+)
+def test_query_result_failure_detects_failure_envelopes(payload):
+    failure = query_result_failure(payload)
+
+    assert failure is not None
+    assert failure.error_type == "QueryError"
+
+
+@pytest.mark.parametrize(
+    "payload",
+    [
+        {"status": "success", "message": "served from cache", "queries": []},
+        {
+            "queries": [
+                {"status": QueryStatus.SUCCESS, "message": "no rows", "data": []}
+            ]
+        },
+        {"queries": [{"status": "running", "message": "in progress", "data": []}]},
+        {"queries": [{"message": "informational", "data": []}]},
+        {"queries": [{"data": []}]},
+    ],
+)
+def test_query_result_failure_allows_valid_and_informational_envelopes(payload):
+    assert query_result_failure(payload) is None
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 7ea2aafa9c5..af10c5024d8 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
@@ -2179,6 +2179,62 @@ class TestUpdateChartValidationGate:
             assert error["error_code"] == "CHART_VALIDATION_FAILED"
             mock_update_cmd_cls.assert_not_called()
 
+    @pytest.mark.asyncio
+    async def test_persist_path_status_failure_skips_db_write(self, mcp_server):
+        """A status-only compile failure must block generate_preview=False."""
+        chart = self._mock_chart_with_dataset(chart_id=42)
+        chart.datasource.id = 10
+        access = DatasetValidationResult(
+            is_valid=True, dataset_id=10, dataset_name="ds", warnings=[]
+        )
+
+        with (
+            patch("superset.db.session"),
+            patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+            patch(
+                "superset.mcp_service.auth.check_chart_data_access",
+                return_value=access,
+            ),
+            patch(
+                "superset.mcp_service.chart.compile.build_dataset_context_from_orm",
+                return_value=None,
+            ),
+            patch(
+                "superset.mcp_service.chart.compile."
+                "DatasetValidator.validate_against_dataset",
+                return_value=(True, None),
+            ),
+            patch(
+                "superset.common.query_context_factory.QueryContextFactory"
+            ) as factory,
+            patch(
+                "superset.commands.chart.data.get_data_command.ChartDataCommand"
+            ) as chart_data_command,
+            patch("superset.commands.chart.update.UpdateChartCommand") as update,
+        ):
+            factory.return_value.create.return_value = Mock()
+            chart_data_command.return_value.run.return_value = {
+                "queries": [{"status": "failed", "message": "boom", "data": []}]
+            }
+            request = {
+                "identifier": 42,
+                "generate_preview": False,
+                "config": {
+                    "chart_type": "table",
+                    "columns": [{"name": "region"}],
+                },
+            }
+
+            async with Client(mcp) as client:
+                result = await client.call_tool("update_chart", {"request": request})
+
+        assert result.structured_content["success"] is False
+        error = result.structured_content["error"]
+        assert error["error_code"] == "CHART_COMPILE_FAILED"
+        assert error["error_type"] == "compile_error"
+        assert "boom" in error["details"]
+        update.assert_not_called()
+
 
 # ---------------------------------------------------------------------------
 # Column normalization in update_chart
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 3f73d64ba3c..358df1d74d5 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
@@ -1444,3 +1444,39 @@ class TestUpdateChartPreviewValidation:
             assert isinstance(error, dict)
             assert error["error_type"] == "DatasetNotAccessible"
             mock_create_form_data.assert_not_called()
+
+
+@pytest.mark.parametrize("allowed", [True, False])
+def test_previous_form_data_uses_existing_explore_access_gate(allowed: bool) -> None:
+    """Cached config retrieval retains Explore's resource-access check."""
+    from superset.commands.dataset.exceptions import DatasetAccessDeniedError
+    from superset.utils.core import DatasourceType
+
+    cached_form_data = '{"viz_type": "gantt_chart", "category": "task"}'
+    state = {
+        "owner": None,
+        "datasource_id": 7,
+        "datasource_type": "table",
+        "chart_id": 12,
+        "form_data": cached_form_data,
+    }
+    with (
+        patch(
+            "superset.commands.explore.form_data.get.app",
+            Mock(config={"EXPLORE_FORM_DATA_CACHE_CONFIG": {}}),
+        ),
+        patch("superset.commands.explore.form_data.get.cache_manager") as cache_manager,
+        patch(
+            "superset.commands.explore.form_data.utils.explore_check_access",
+            side_effect=None if allowed else DatasetAccessDeniedError(),
+        ) as check_access,
+    ):
+        cache_manager.explore_form_data_cache.get.return_value = state
+
+        result = update_chart_preview_module._get_previous_form_data("cached-key")
+
+    cache_manager.explore_form_data_cache.get.assert_called_once_with("cached-key")
+    check_access.assert_called_once_with(7, 12, DatasourceType.TABLE)
+    assert result == (
+        {"viz_type": "gantt_chart", "category": "task"} if allowed else None
+    )
diff --git a/tests/unit_tests/migrations/test_single_migration_head.py b/tests/unit_tests/migrations/test_single_migration_head.py
new file mode 100644
index 00000000000..36606d6c821
--- /dev/null
+++ b/tests/unit_tests/migrations/test_single_migration_head.py
@@ -0,0 +1,29 @@
+# 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.
+"""Regression coverage for a connected Alembic migration graph."""
+
+from pathlib import Path
+
+from alembic.script import ScriptDirectory
+
+
+def test_migrations_have_one_head() -> None:
+    """Database initialization must have an unambiguous upgrade target."""
+    migrations = Path(__file__).resolve().parents[3] / "superset" / "migrations"
+    scripts = ScriptDirectory(str(migrations))
+
+    assert len(scripts.get_heads()) == 1