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 342a8f1e4e0 fix(mcp): report a chart's live dataset id and name 
(#44681)
342a8f1e4e0 is described below

commit 342a8f1e4e0361833a42cabc907fb92af5e0bbdc
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Fri Oct 2 13:19:32 2026 +1000

    fix(mcp): report a chart's live dataset id and name (#44681)
---
 docs/admin_docs/configuration/mcp-server.mdx       |  17 +
 superset/mcp_service/chart/schemas.py              |  61 +-
 superset/mcp_service/chart/tool/get_chart_info.py  |  18 +-
 superset/mcp_service/chart/tool/get_chart_sql.py   |   5 +-
 superset/mcp_service/chart/tool/list_charts.py     | 118 +++-
 superset/mcp_service/common/schema_discovery.py    |   2 +-
 superset/mcp_service/dashboard/schemas.py          |  29 +-
 superset/mcp_service/privacy.py                    |   1 +
 .../chart/test_live_chart_datasource.py            | 709 +++++++++++++++++++++
 .../dashboard/test_dashboard_schemas.py            |  18 +
 10 files changed, 959 insertions(+), 19 deletions(-)

diff --git a/docs/admin_docs/configuration/mcp-server.mdx 
b/docs/admin_docs/configuration/mcp-server.mdx
index 6c2c6889244..84119429973 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1195,6 +1195,23 @@ while page["next_cursor"]:
     )
 ```
 
+## Chart datasource metadata
+
+For roles allowed to view data-model metadata, `get_chart_info` includes
+`datasource_id` in its default columns. Dashboard chart summaries include
+`datasource_type` alongside `datasource_id`, which is only unique within its 
type.
+Both fields are redacted for users without data-model metadata access.
+Chart and dashboard chart summaries resolve dataset names from the live table
+or semantic view, so renames are reflected without resaving the chart. Query 
and
+saved-query charts retain their stored datasource names; missing tables or
+semantic views do not fall back to stale names. Dataset-access validation 
remains
+unchanged.
+
+`list_charts` filters on `datasource_name` use these same live names before
+pagination and counting. For tables with a schema, filter using
+`schema.table_name`. Data-model metadata permission checks also apply to these
+filters and returned identifiers.
+
 ## Trash Listing (Soft-Deleted Objects)
 
 When the `SOFT_DELETE` feature flag is enabled, deleting a chart or dashboard 
moves it to trash rather than removing it outright. The `list_charts` and 
`list_dashboards` tools accept a `deleted_state` parameter to surface those 
trashed objects:
diff --git a/superset/mcp_service/chart/schemas.py 
b/superset/mcp_service/chart/schemas.py
index 992a1d7d2ad..9b14c9ab8c2 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -95,6 +95,7 @@ class ChartLike(Protocol):
     id: int
     slice_name: str | None
     viz_type: str | None
+    datasource_id: int | None
     datasource_name: str | None
     datasource_type: str | None
     url: str | None
@@ -131,7 +132,16 @@ class ChartInfo(BaseModel):
             "fall back to viz_type when this field is null."
         ),
     )
-    datasource_name: str | None = Field(None, description="Datasource name")
+    datasource_id: int | None = Field(
+        None, description="ID of the dataset (or semantic view) the chart 
queries"
+    )
+    datasource_name: str | None = Field(
+        None,
+        description=(
+            "Current name of the dataset (or semantic view) the chart queries, 
"
+            "resolved from the live datasource"
+        ),
+    )
     datasource_type: str | None = Field(None, description="Datasource type")
     url: str | None = Field(None, description="Chart explore page URL")
     description: str | None = Field(None, description="Chart description")
@@ -304,6 +314,7 @@ class VersionedResponse(BaseModel):
 
 DEFAULT_GET_CHART_INFO_COLUMNS: List[str] = [
     "id",
+    "datasource_id",
     "slice_name",
     "viz_type",
     "datasource_name",
@@ -552,7 +563,44 @@ CHART_FORM_DATA_EXCLUDED_FIELD_NAMES = frozenset(
 )
 
 
-def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
+def resolve_chart_datasource_id(chart: Any) -> int | None:
+    """Return the ID of the datasource a chart is joined to."""
+    datasource_id = getattr(chart, "datasource_id", None)
+    if isinstance(datasource_id, int) and not isinstance(datasource_id, bool):
+        return datasource_id
+    return None
+
+
+def resolve_chart_datasource_name(chart: Any) -> str | None:
+    """Return the chart's datasource name, read from the live datasource.
+
+    ``Slice.datasource_name`` is a stored, denormalized column that is not
+    refreshed when a dataset is renamed or a chart is re-pointed, so it can
+    name a table the chart no longer queries. ``Slice.datasource_name_text``
+    resolves the name through the type-guarded ``table`` / ``semantic_view``
+    relationships instead, and yields ``None`` when the datasource no longer
+    exists. Query and saved-query charts, and objects without that resolver
+    (row tuples, lightweight stand-ins), fall back to the stored value.
+    """
+    resolver = getattr(chart, "datasource_name_text", None)
+    if callable(resolver) and getattr(chart, "datasource_type", None) not in {
+        "query",
+        "saved_query",
+    }:
+        live_name = resolver()
+        if live_name is None or isinstance(live_name, str):
+            return live_name
+    stored_name = getattr(chart, "datasource_name", None)
+    return stored_name if isinstance(stored_name, str) else None
+
+
+def serialize_chart_object(
+    chart: ChartLike | None, *, select_columns: list[str] | None = None
+) -> ChartInfo | None:
+    """Serialize a chart, loading collection relationships only when requested.
+
+    Omitting ``select_columns`` preserves full-object serialization.
+    """
     if not chart:
         return None
 
@@ -598,7 +646,8 @@ def serialize_chart_object(chart: ChartLike | None) -> 
ChartInfo | None:
         slice_name=getattr(chart, "slice_name", None),
         viz_type=_viz_type,
         chart_type_display_name=_display_name,
-        datasource_name=getattr(chart, "datasource_name", None),
+        datasource_id=resolve_chart_datasource_id(chart),
+        datasource_name=resolve_chart_datasource_name(chart),
         datasource_type=getattr(chart, "datasource_type", None),
         url=chart_url,
         description=getattr(chart, "description", None),
@@ -617,14 +666,16 @@ def serialize_chart_object(chart: ChartLike | None) -> 
ChartInfo | None:
             TagInfo.model_validate(tag, from_attributes=True)
             for tag in getattr(chart, "tags", [])
         ]
-        if getattr(chart, "tags", None)
+        if (select_columns is None or "tags" in select_columns)
+        and getattr(chart, "tags", None)
         else [],
         editors=[
             info
             for editor in getattr(chart, "editors", [])
             if (info := serialize_subject_object(editor)) is not None
         ]
-        if getattr(chart, "editors", None)
+        if (select_columns is None or "editors" in select_columns)
+        and getattr(chart, "editors", None)
         else [],
     )
 
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py 
b/superset/mcp_service/chart/tool/get_chart_info.py
index 7dc5f41a947..b2728a9b13f 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -163,8 +163,8 @@ def _permalink_form_data(permalink: ExplorePermalinkValue) 
-> dict[str, Any]:
 
 def _permalink_datasource(
     permalink: ExplorePermalinkValue,
-) -> tuple[str | None, str | None]:
-    """Return the (name, type) of the datasource a permalink was built on.
+) -> tuple[int | None, str | None, str | None]:
+    """Return the (id, name, type) of the datasource a permalink was built on.
 
     A permalink's form_data carries the datasource as an opaque "<id>__<type>"
     string, so the name has to be resolved from the ids the permalink stores
@@ -174,7 +174,7 @@ def _permalink_datasource(
     datasource_type = permalink.get("datasourceType") or 
DatasourceType.TABLE.value
     datasource_id = permalink.get("datasourceId") or permalink.get("datasetId")
     if not datasource_id:
-        return None, str(datasource_type)
+        return None, None, str(datasource_type)
     try:
         from superset.daos.datasource import DatasourceDAO
 
@@ -190,21 +190,27 @@ def _permalink_datasource(
             datasource_id,
             datasource_type,
         )
-        return None, str(datasource_type)
+        return None, None, str(datasource_type)
     # A SQL Lab query labels itself with ``name``; datasets use
     # ``datasource_name``.
     name = getattr(datasource, "datasource_name", None) or getattr(
         datasource, "name", None
     )
-    return name, str(datasource_type)
+    resolved_id = getattr(datasource, "id", None)
+    return (
+        resolved_id if isinstance(resolved_id, int) else None,
+        name,
+        str(datasource_type),
+    )
 
 
 def _build_permalink_chart_info(
     permalink_key: str, permalink: ExplorePermalinkValue, form_data: dict[str, 
Any]
 ) -> ChartInfo:
     """Build a ChartInfo from a permalink that is not tied to a saved chart."""
-    datasource_name, datasource_type = _permalink_datasource(permalink)
+    datasource_id, datasource_name, datasource_type = 
_permalink_datasource(permalink)
     result = ChartInfo(
+        datasource_id=datasource_id,
         datasource_name=datasource_name,
         datasource_type=datasource_type,
         form_data=form_data,
diff --git a/superset/mcp_service/chart/tool/get_chart_sql.py 
b/superset/mcp_service/chart/tool/get_chart_sql.py
index d441b3a02df..76871028909 100644
--- a/superset/mcp_service/chart/tool/get_chart_sql.py
+++ b/superset/mcp_service/chart/tool/get_chart_sql.py
@@ -49,6 +49,7 @@ from superset.mcp_service.chart.schemas import (
     ChartError,
     ChartSql,
     GetChartSqlRequest,
+    resolve_chart_datasource_name,
 )
 
 logger = logging.getLogger(__name__)
@@ -247,7 +248,7 @@ def _sql_from_saved_query_context(
             result,
             chart.id,
             chart.slice_name,
-            chart.datasource_name,
+            resolve_chart_datasource_name(chart),
             extra_form_data=extra_form_data,
         )
     except SupersetSecurityException:
@@ -266,7 +267,7 @@ def _resolve_datasource_name(
     from form_data so that the response includes a meaningful name.
     """
     if chart:
-        return getattr(chart, "datasource_name", None)
+        return resolve_chart_datasource_name(chart)
 
     # Unsaved chart — resolve from form_data
     datasource_id = form_data.get("datasource_id")
diff --git a/superset/mcp_service/chart/tool/list_charts.py 
b/superset/mcp_service/chart/tool/list_charts.py
index 09185249ba9..4a41df1c8d2 100644
--- a/superset/mcp_service/chart/tool/list_charts.py
+++ b/superset/mcp_service/chart/tool/list_charts.py
@@ -20,9 +20,10 @@ MCP tool: list_charts (advanced filtering with metadata 
cache control)
 """
 
 import logging
-from typing import cast, TYPE_CHECKING
+from typing import Any, cast, TYPE_CHECKING
 
 from fastmcp import Context
+from sqlalchemy import case, select
 from superset_core.mcp.decorators import tool, ToolAnnotations
 
 if TYPE_CHECKING:
@@ -63,6 +64,109 @@ DEFAULT_CHART_COLUMNS = [
 
 _DEFAULT_LIST_CHARTS_REQUEST = ListChartsRequest()
 
+# Relationships that resolve a chart's live datasource. ``datasource_name`` is
+# read through them rather than from the stored ``Slice.datasource_name``
+# column, which can be stale after a dataset rename or a chart re-point.
+_LIVE_DATASOURCE_RELATIONSHIPS = ("table", "semantic_view")
+
+
+class _LiveDatasourceNameFilter:
+    """Apply name operators to the same type-guarded datasource as 
serialization."""
+
+    def __init__(self, filters: list[ChartFilter]) -> None:
+        """Bind the validated name filters for the DAO invocation."""
+        self.filters = filters
+
+    def apply(self, query: Any, value: Any) -> Any:
+        """Filter in SQL so counts and pagination reflect the live names."""
+        from superset.connectors.sqla.models import SqlaTable
+        from superset.models.slice import Slice
+        from superset.semantic_layers.models import SemanticView
+
+        # Keep this SQL expression aligned with SqlaTable.name, which supplies
+        # the table name used by resolve_chart_datasource_name during 
serialization.
+        table_name = case(
+            (
+                (SqlaTable.schema.isnot(None)) & (SqlaTable.schema != ""),
+                SqlaTable.schema + "." + SqlaTable.table_name,
+            ),
+            else_=SqlaTable.table_name,
+        )
+        live_name = case(
+            (
+                Slice.datasource_type == "table",
+                select(table_name)
+                .where(SqlaTable.id == Slice.datasource_id)
+                .correlate(Slice)
+                .scalar_subquery(),
+            ),
+            (
+                Slice.datasource_type == "semantic_view",
+                select(SemanticView.name)
+                .where(SemanticView.id == Slice.datasource_id)
+                .correlate(Slice)
+                .scalar_subquery(),
+            ),
+            (
+                Slice.datasource_type.in_(["query", "saved_query"]),
+                Slice.datasource_name,
+            ),
+            else_=None,
+        )
+        for name_filter in self.filters:
+            query = query.filter(name_filter.opr.apply(live_name, 
name_filter.value))
+        return query
+
+
+class _ChartListCore(ModelListCore[ChartList]):
+    """List core that loads the live datasource whenever its name is requested.
+
+    Requesting only plain columns makes the DAO return row tuples, which cannot
+    reach the ``table`` / ``semantic_view`` relationships. Adding them to the
+    DAO load list switches it to full model instances with the relationships
+    eager-loaded, so the live name costs no per-chart query.
+    """
+
+    def _call_dao_list(
+        self,
+        filters: Any,
+        order_column: str,
+        order_direction: str,
+        page: int,
+        page_size: int,
+        search: str | None,
+        columns_to_load: list[str],
+        custom_filters: dict[str, Any] | None = None,
+    ) -> tuple[list[Any], int]:
+        name_filters = [
+            item for item in (filters or []) if item.col == "datasource_name"
+        ]
+        if name_filters:
+            filters = [item for item in filters if item.col != 
"datasource_name"]
+            custom_filters = {
+                **(custom_filters or {}),
+                "live_datasource_name": 
_LiveDatasourceNameFilter(name_filters),
+            }
+        if "datasource_name" in columns_to_load:
+            columns_to_load = [
+                *columns_to_load,
+                *(
+                    rel
+                    for rel in _LIVE_DATASOURCE_RELATIONSHIPS
+                    if rel not in columns_to_load
+                ),
+            ]
+        return super()._call_dao_list(
+            filters=filters,
+            order_column=order_column,
+            order_direction=order_direction,
+            page=page,
+            page_size=page_size,
+            search=search,
+            columns_to_load=columns_to_load,
+            custom_filters=custom_filters,
+        )
+
 
 @tool(
     tags=["core"],
@@ -161,9 +265,9 @@ async def list_charts(
         obj: "Slice | None", cols: list[str] | None
     ) -> ChartInfo | None:
         """Serialize chart object (field filtering handled by 
model_serializer)."""
-        return serialize_chart_object(cast(ChartLike | None, obj))
+        return serialize_chart_object(cast(ChartLike | None, obj), 
select_columns=cols)
 
-    tool = ModelListCore(
+    tool = _ChartListCore(
         dao_class=ChartDAO,
         output_schema=ChartInfo,
         item_serializer=_serialize_chart,
@@ -214,6 +318,14 @@ async def list_charts(
         # Always use columns_requested (either explicit select_columns or 
defaults)
         # This triggers ChartInfo._filter_fields_by_context for each chart
         columns_to_filter = result.columns_requested
+        # Report only fields that the serialized charts actually carry: drop
+        # model columns ChartInfo does not expose (e.g. params, perm) and
+        # dependencies loaded solely to compute another field.
+        result.columns_loaded = [
+            column
+            for column in result.columns_loaded
+            if column in columns_to_filter and column in ChartInfo.model_fields
+        ]
         await ctx.debug(
             "Applying field filtering via serialization context: columns=%s"
             % (columns_to_filter,)
diff --git a/superset/mcp_service/common/schema_discovery.py 
b/superset/mcp_service/common/schema_discovery.py
index 9e1ee130a8f..e6229677f99 100644
--- a/superset/mcp_service/common/schema_discovery.py
+++ b/superset/mcp_service/common/schema_discovery.py
@@ -264,7 +264,7 @@ CHART_SEARCH_COLUMNS = ["slice_name", "description"]
 CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
     "datasource_name": ColumnMetadata(
         name="datasource_name",
-        description="Data source name",
+        description="Current name of the chart's dataset or semantic view",
         type="str",
         is_default=False,
     ),
diff --git a/superset/mcp_service/dashboard/schemas.py 
b/superset/mcp_service/dashboard/schemas.py
index 82073216082..6b5d0d419a0 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -90,6 +90,10 @@ if TYPE_CHECKING:
 
 from superset.daos.base import ColumnOperator, ColumnOperatorEnum
 from superset.exceptions import SupersetSecurityException
+from superset.mcp_service.chart.schemas import (
+    resolve_chart_datasource_id,
+    resolve_chart_datasource_name,
+)
 from superset.mcp_service.common.cache_schemas import (
     CreatedByMeMixin,
     EditedByMeMixin,
@@ -429,7 +433,22 @@ class DashboardChartSummary(BaseModel):
     id: int | None = Field(None, description="Chart ID")
     slice_name: str | None = Field(None, description="Chart name")
     viz_type: str | None = Field(None, description="Visualization type")
-    datasource_name: str | None = Field(None, description="Datasource name")
+    datasource_id: int | None = Field(
+        None, description="ID of the dataset (or semantic view) the chart 
queries"
+    )
+    datasource_type: str | None = Field(
+        None,
+        description=(
+            "Type of the datasource; datasource_id is only unique within this 
type"
+        ),
+    )
+    datasource_name: str | None = Field(
+        None,
+        description=(
+            "Current name of the dataset (or semantic view) the chart queries, 
"
+            "resolved from the live datasource"
+        ),
+    )
     url: str | None = Field(None, description="Chart explore page URL")
     description: str | None = Field(None, description="Chart description")
 
@@ -1867,7 +1886,13 @@ def serialize_chart_summary(
         id=chart_id,
         slice_name=getattr(chart, "slice_name", None),
         viz_type=getattr(chart, "viz_type", None),
-        datasource_name=getattr(chart, "datasource_name", None)
+        datasource_id=resolve_chart_datasource_id(chart)
+        if include_data_model_metadata
+        else None,
+        datasource_type=getattr(chart, "datasource_type", None)
+        if include_data_model_metadata
+        else None,
+        datasource_name=resolve_chart_datasource_name(chart)
         if include_data_model_metadata
         else None,
         url=chart_url,
diff --git a/superset/mcp_service/privacy.py b/superset/mcp_service/privacy.py
index a007772ad69..a82d5115e33 100644
--- a/superset/mcp_service/privacy.py
+++ b/superset/mcp_service/privacy.py
@@ -183,6 +183,7 @@ def redact_chart_data_model_fields(chart_info: Any) -> Any:
     if isinstance(chart_info, ChartInfo):
         return chart_info.model_copy(
             update={
+                "datasource_id": None,
                 "datasource_name": None,
                 "datasource_type": None,
                 "filters": None,
diff --git a/tests/unit_tests/mcp_service/chart/test_live_chart_datasource.py 
b/tests/unit_tests/mcp_service/chart/test_live_chart_datasource.py
new file mode 100644
index 00000000000..72a85a25898
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_live_chart_datasource.py
@@ -0,0 +1,709 @@
+# 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.
+"""Chart responses report the chart's live dataset, not its stored name.
+
+``Slice.datasource_name`` is a denormalized column that is not refreshed when
+a dataset is renamed or a chart is re-pointed. These tests persist charts whose
+stored name disagrees with the dataset (or semantic view) they are joined to,
+reload them from a cold identity map, and check that every MCP chart surface
+reports the live id and name.
+"""
+
+from collections.abc import Iterator
+from contextlib import nullcontext
+from dataclasses import dataclass
+from importlib import import_module
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+from sqlalchemy import create_engine
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Load, Session
+from sqlalchemy.pool import StaticPool
+
+from superset.connectors.sqla.models import SqlaTable
+from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.schemas import (
+    ChartInfo,
+    resolve_chart_datasource_id,
+    resolve_chart_datasource_name,
+    serialize_chart_object,
+)
+from superset.mcp_service.chart.tool.get_chart_sql import 
_resolve_datasource_name
+from superset.mcp_service.dashboard.schemas import serialize_chart_summary
+from superset.mcp_service.privacy import redact_chart_data_model_fields
+from superset.models.core import Database
+from superset.models.slice import Slice
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.utils import json
+
+get_chart_info_module = 
import_module("superset.mcp_service.chart.tool.get_chart_info")
+list_charts_module = 
import_module("superset.mcp_service.chart.tool.list_charts")
+
+STALE_TABLE_NAME = "superset_events_production.superset_events"
+STALE_VIEW_NAME = "retired_view"
+
+
+@dataclass(frozen=True)
+class PersistedCharts:
+    table_chart_id: int
+    view_chart_id: int
+    orphan_chart_id: int
+    table_id: int
+    view_id: int
+
+
[email protected]
+def mcp_server() -> object:
+    return mcp
+
+
[email protected]
+def session_engine() -> Engine:
+    """Let the tool worker thread use the ``session`` built by this test."""
+    # The injected Session is handed from setup to a single tool worker
+    # sequentially; concurrent ownership is covered with scoped sessions.
+    return create_engine(
+        "sqlite://", connect_args={"check_same_thread": False}, 
poolclass=StaticPool
+    )
+
+
[email protected](autouse=True)
+def mock_auth() -> Iterator[None]:
+    with patch("superset.mcp_service.auth.get_user_from_request") as 
mock_get_user:
+        mock_user = Mock()
+        mock_user.id = 1
+        mock_user.username = "admin"
+        mock_get_user.return_value = mock_user
+        yield
+
+
[email protected]
+def charts(session: Session) -> PersistedCharts:
+    """Persist charts whose stored ``datasource_name`` is stale."""
+    engine = session.get_bind()
+    assert isinstance(engine, Engine)
+    Slice.metadata.create_all(engine)
+
+    database = Database(database_name="analytics", sqlalchemy_uri="sqlite://")
+    table = SqlaTable(
+        table_name="hs_flat_customer_events",
+        schema="hubspot_customers",
+        database=database,
+    )
+    layer = SemanticLayer(name="Jaffle Shop", type="test")
+    session.add_all([database, table, layer])
+    session.flush()
+    view = SemanticView(name="Orders", semantic_layer_uuid=layer.uuid)
+    session.add(view)
+    session.flush()
+
+    table_chart = Slice(
+        slice_name="Customer events",
+        viz_type="table",
+        datasource_type="table",
+        datasource_id=table.id,
+        datasource_name=STALE_TABLE_NAME,
+    )
+    view_chart = Slice(
+        slice_name="Orders by day",
+        viz_type="table",
+        datasource_type="semantic_view",
+        datasource_id=view.id,
+        datasource_name=STALE_VIEW_NAME,
+    )
+    orphan_chart = Slice(
+        slice_name="Dataset deleted",
+        viz_type="table",
+        datasource_type="table",
+        datasource_id=table.id + view.id + 100,
+        datasource_name=STALE_TABLE_NAME,
+    )
+    session.add_all([table_chart, view_chart, orphan_chart])
+    session.flush()
+    persisted = PersistedCharts(
+        table_chart_id=table_chart.id,
+        view_chart_id=view_chart.id,
+        orphan_chart_id=orphan_chart.id,
+        table_id=table.id,
+        view_id=view.id,
+    )
+    # The same id for a dataset and a semantic view checks that each chart is
+    # resolved through the relationship matching its own datasource type.
+    assert persisted.table_id == persisted.view_id
+    session.expunge_all()
+    return persisted
+
+
+def _load(session: Session, chart_id: int) -> Slice:
+    return session.query(Slice).filter(Slice.id == chart_id).one()
+
+
+def test_serialize_chart_object_reports_live_dataset(
+    session: Session, charts: PersistedCharts
+) -> None:
+    info = serialize_chart_object(_load(session, charts.table_chart_id))
+
+    assert info is not None
+    assert info.datasource_id == charts.table_id
+    assert info.datasource_name == "hubspot_customers.hs_flat_customer_events"
+
+
+def test_serialize_chart_object_reports_live_semantic_view(
+    session: Session, charts: PersistedCharts
+) -> None:
+    info = serialize_chart_object(_load(session, charts.view_chart_id))
+
+    assert info is not None
+    assert info.datasource_id == charts.view_id
+    assert info.datasource_name == "Orders"
+    assert info.datasource_type == "semantic_view"
+
+
+def test_missing_dataset_reports_no_name_rather_than_stale_one(
+    session: Session, charts: PersistedCharts
+) -> None:
+    chart = _load(session, charts.orphan_chart_id)
+
+    assert resolve_chart_datasource_name(chart) is None
+    assert resolve_chart_datasource_id(chart) == chart.datasource_id
+
+
+def test_get_chart_sql_names_the_live_dataset(
+    session: Session, charts: PersistedCharts
+) -> None:
+    chart = _load(session, charts.table_chart_id)
+
+    assert (
+        _resolve_datasource_name({}, chart)
+        == "hubspot_customers.hs_flat_customer_events"
+    )
+
+
+def test_objects_without_live_resolver_fall_back_to_stored_name() -> None:
+    row = SimpleNamespace(datasource_id=7, datasource_name="stored")
+
+    assert resolve_chart_datasource_name(row) == "stored"
+    assert resolve_chart_datasource_id(row) == 7
+
+
[email protected](
+    ("chart_attr", "id_attr", "expected_name", "expected_type"),
+    [
+        (
+            "table_chart_id",
+            "table_id",
+            "hubspot_customers.hs_flat_customer_events",
+            "table",
+        ),
+        ("view_chart_id", "view_id", "Orders", "semantic_view"),
+    ],
+)
+def test_dashboard_chart_summary_reports_live_dataset(
+    session: Session,
+    charts: PersistedCharts,
+    chart_attr: str,
+    id_attr: str,
+    expected_name: str,
+    expected_type: str,
+) -> None:
+    chart = _load(session, getattr(charts, chart_attr))
+
+    summary = serialize_chart_summary(chart, include_data_model_metadata=True)
+    redacted = serialize_chart_summary(chart, 
include_data_model_metadata=False)
+
+    assert summary is not None
+    assert summary.datasource_id == getattr(charts, id_attr)
+    assert summary.datasource_type == expected_type
+    assert summary.model_dump()["datasource_type"] == expected_type
+    assert summary.datasource_name == expected_name
+    assert redacted is not None
+    assert redacted.datasource_id is None
+    assert redacted.datasource_type is None
+    assert redacted.datasource_name is None
+
+
+def test_redaction_clears_datasource_id() -> None:
+    redacted = redact_chart_data_model_fields(
+        ChartInfo(
+            id=1, datasource_id=14, datasource_type="table", 
datasource_name="orders"
+        )
+    )
+
+    assert redacted.datasource_id is None
+    assert redacted.datasource_type is None
+    assert redacted.datasource_name is None
+
+
+def _find_by_id(session: Session) -> Any:
+    def find(
+        identifier: int, query_options: list[Load] | None = None, **_: Any
+    ) -> Slice | None:
+        return (
+            session.query(Slice)
+            .options(*(query_options or []))
+            .filter(Slice.id == identifier)
+            .one_or_none()
+        )
+
+    return find
+
+
+async def _call_get_chart_info(
+    session: Session,
+    chart_id: int,
+    *,
+    can_view_data_model: bool,
+    use_defaults: bool = False,
+) -> dict[str, Any]:
+    with (
+        patch.object(
+            get_chart_info_module.event_logger,
+            "log_context",
+            return_value=nullcontext(),
+        ),
+        patch.object(
+            get_chart_info_module,
+            "user_can_view_data_model_metadata",
+            return_value=can_view_data_model,
+        ),
+        patch.object(
+            get_chart_info_module,
+            "validate_chart_dataset",
+            return_value=SimpleNamespace(is_valid=True, warnings=[]),
+        ),
+        patch(
+            "superset.daos.chart.ChartDAO.find_by_id",
+            side_effect=_find_by_id(session),
+        ),
+        patch("superset.mcp_service.auth.check_tool_permission", 
return_value=True),
+    ):
+        async with Client(mcp) as client:
+            response = await client.call_tool(
+                "get_chart_info",
+                {
+                    "request": {
+                        "identifier": chart_id,
+                        **(
+                            {}
+                            if use_defaults
+                            else {
+                                "select_columns": [
+                                    "id",
+                                    "slice_name",
+                                    "datasource_id",
+                                    "datasource_name",
+                                ]
+                            }
+                        ),
+                    }
+                },
+            )
+    return json.loads(response.content[0].text)
+
+
[email protected]
+async def test_get_chart_info_returns_live_datasource_id_and_name(
+    session: Session, charts: PersistedCharts
+) -> None:
+    result = await _call_get_chart_info(
+        session, charts.table_chart_id, can_view_data_model=True
+    )
+
+    assert result == {
+        "id": charts.table_chart_id,
+        "slice_name": "Customer events",
+        "datasource_id": charts.table_id,
+        "datasource_name": "hubspot_customers.hs_flat_customer_events",
+    }
+
+
[email protected]
+async def test_get_chart_info_redacts_datasource_id_without_data_model_access(
+    session: Session, charts: PersistedCharts
+) -> None:
+    result = await _call_get_chart_info(
+        session, charts.table_chart_id, can_view_data_model=False
+    )
+
+    assert result["datasource_id"] is None
+    assert result["datasource_name"] is None
+
+
+def _list_charts_dao(session: Session, calls: list[list[str]]) -> Any:
+    def list_(*_: Any, columns: list[str] | None = None, **__: Any) -> Any:
+        calls.append(list(columns or []))
+        rows = session.query(Slice).order_by(Slice.id).all()
+        return rows, len(rows)
+
+    return list_
+
+
+async def _call_list_charts(
+    session: Session,
+    select_columns: list[str],
+    *,
+    can_view_data_model: bool,
+) -> tuple[dict[str, Any], list[list[str]]]:
+    calls: list[list[str]] = []
+    with (
+        patch.object(
+            list_charts_module,
+            "user_can_view_data_model_metadata",
+            return_value=can_view_data_model,
+        ),
+        patch(
+            "superset.daos.chart.ChartDAO.list",
+            side_effect=_list_charts_dao(session, calls),
+        ),
+    ):
+        async with Client(mcp) as client:
+            response = await client.call_tool(
+                "list_charts",
+                {"request": {"select_columns": select_columns}},
+            )
+    return json.loads(response.content[0].text), calls
+
+
[email protected]
+async def test_list_charts_returns_live_datasource_id_and_name(
+    session: Session, charts: PersistedCharts
+) -> None:
+    data, calls = await _call_list_charts(
+        session,
+        ["id", "datasource_id", "datasource_name", "params"],
+        can_view_data_model=True,
+    )
+
+    # The live-datasource relationships are eager-loaded with the charts.
+    assert {"table", "semantic_view"} <= set(calls[0])
+    by_id = {chart["id"]: chart for chart in data["charts"]}
+    assert by_id[charts.table_chart_id] == {
+        "id": charts.table_chart_id,
+        "datasource_id": charts.table_id,
+        "datasource_name": "hubspot_customers.hs_flat_customer_events",
+    }
+    assert by_id[charts.view_chart_id] == {
+        "id": charts.view_chart_id,
+        "datasource_id": charts.view_id,
+        "datasource_name": "Orders",
+    }
+    assert by_id[charts.orphan_chart_id]["datasource_name"] is None
+    # ``params`` is a model column that ChartInfo does not expose.
+    assert data["columns_loaded"] == ["id", "datasource_id", "datasource_name"]
+
+
[email protected]
+async def test_list_charts_omits_datasource_id_without_data_model_access(
+    session: Session, charts: PersistedCharts
+) -> None:
+    data, _ = await _call_list_charts(
+        session,
+        ["id", "slice_name", "datasource_id", "datasource_name"],
+        can_view_data_model=False,
+    )
+
+    assert data["columns_loaded"] == ["id", "slice_name"]
+    for chart in data["charts"]:
+        assert set(chart) == {"id", "slice_name"}
+
+
[email protected]
+async def test_list_charts_columns_loaded_excludes_computation_dependencies(
+    session: Session, charts: PersistedCharts
+) -> None:
+    data, calls = await _call_list_charts(
+        session, ["id", "changed_on_humanized"], can_view_data_model=True
+    )
+
+    assert "changed_on" in calls[0]
+    assert data["columns_loaded"] == ["id", "changed_on_humanized"]
+    assert "table" not in calls[0]
+
+
[email protected]
+async def test_list_charts_live_datasource_query_count_is_bounded(
+    session: Session, charts: PersistedCharts
+) -> None:
+    """Real DAO queries must not lazy-load relationships for each chart."""
+    from sqlalchemy import event
+
+    from superset.daos.chart import ChartDAO
+
+    statements: list[str] = []
+
+    def record_statement(
+        conn: Any,
+        cursor: Any,
+        statement: str,
+        parameters: Any,
+        context: Any,
+        executemany: bool,
+    ) -> None:
+        """Count SELECTs during the tool call, excluding fixture setup."""
+        if statement.lstrip().upper().startswith("SELECT"):
+            statements.append(statement)
+
+    counts: list[int] = []
+    with (
+        patch.object(
+            list_charts_module,
+            "user_can_view_data_model_metadata",
+            return_value=True,
+        ),
+        # Isolate authorization; keep the DAO's query construction and 
execution.
+        patch.object(
+            ChartDAO, "_apply_base_filter", side_effect=lambda query, **_: 
query
+        ),
+    ):
+        async with Client(mcp) as client:
+            for page_size in (1, 3):
+                session.expunge_all()
+                statements.clear()
+                engine = session.get_bind()
+                event.listen(engine, "before_cursor_execute", record_statement)
+                try:
+                    response = await client.call_tool(
+                        "list_charts",
+                        {
+                            "request": {
+                                "select_columns": [
+                                    "id",
+                                    "datasource_id",
+                                    "datasource_name",
+                                    "params",
+                                ],
+                                "order_column": "id",
+                                "order_direction": "asc",
+                                "page_size": page_size,
+                            }
+                        },
+                    )
+                finally:
+                    event.remove(engine, "before_cursor_execute", 
record_statement)
+                data = json.loads(response.content[0].text)
+                assert len(data["charts"]) == page_size
+                assert data["charts"][0]["datasource_id"] == charts.table_id
+                assert data["charts"][0]["datasource_name"] == (
+                    "hubspot_customers.hs_flat_customer_events"
+                )
+                counts.append(len(statements))
+
+    assert counts == [2, 2], f"SELECT counts for 1 and 3 charts: {counts}"
+
+
[email protected](
+    "select_columns", [None, ["tags"], ["editors"], ["tags", "editors"]]
+)
+def test_serialize_chart_preserves_requested_collections(
+    select_columns: list[str] | None,
+) -> None:
+    """Full-object and explicit collection reads retain their existing 
shape."""
+    from superset.subjects.models import Subject
+    from superset.tags.models import Tag
+
+    chart = Slice(
+        tags=[Tag(id=7, name="sales")],
+        editors=[Subject(id=9, label="Chart editor", active=True)],
+    )
+    info = serialize_chart_object(chart, select_columns=select_columns)
+
+    assert info is not None
+    assert [tag.model_dump() for tag in info.tags] == (
+        [{"id": 7, "name": "sales", "type": None, "description": None}]
+        if select_columns is None or "tags" in select_columns
+        else []
+    )
+    assert [editor.model_dump() for editor in info.editors] == (
+        [{"id": 9, "label": "Chart editor", "type": None, "active": True}]
+        if select_columns is None or "editors" in select_columns
+        else []
+    )
+
+
[email protected]("datasource_type", ["query", "saved_query"])
[email protected]("surface", ["chart", "dashboard"])
+def test_non_dataset_chart_keeps_stored_name(
+    datasource_type: str, surface: str
+) -> None:
+    """Query-backed charts retain their names on both metadata surfaces."""
+    chart = Slice(
+        id=123,
+        datasource_type=datasource_type,
+        datasource_id=7,
+        datasource_name="Saved analysis",
+    )
+    if surface == "chart":
+        result = serialize_chart_object(chart)
+    else:
+        result = serialize_chart_summary(chart, 
include_data_model_metadata=True)
+    assert result is not None
+    assert result.datasource_name == "Saved analysis"
+    assert result.datasource_id == 7
+    assert result.datasource_type == datasource_type
+    if surface == "dashboard":
+        redacted = serialize_chart_summary(chart)
+        assert redacted is not None
+        assert redacted.datasource_id is None
+        assert redacted.datasource_type is None
+        assert redacted.datasource_name is None
+
+
+def test_default_chart_info_includes_datasource_id() -> None:
+    """Default column selection includes the dataset identifier."""
+    from superset.mcp_service.chart.schemas import GetChartInfoRequest
+
+    assert "datasource_id" in GetChartInfoRequest(identifier=1).select_columns
+
+
[email protected]
[email protected](
+    ("operator", "value", "expected"),
+    [
+        ("eq", "hubspot_customers.hs_flat_customer_events", "table"),
+        ("eq", STALE_TABLE_NAME, "none"),
+        ("eq", "Orders", "view"),
+        ("eq", STALE_VIEW_NAME, "none"),
+        ("ct", "flat_customer", "table"),
+        ("sw", "hubspot_customers.", "table"),
+        ("in", ["Orders", "hubspot_customers.hs_flat_customer_events"], 
"both"),
+        ("is_null", "", "orphan"),
+        ("ne", "Orders", "table"),
+    ],
+)
+async def test_list_filters_live_datasource_names(
+    session: Session,
+    charts: PersistedCharts,
+    operator: str,
+    value: str | list[str],
+    expected: str,
+) -> None:
+    """The real DAO filters live names before counting and pagination."""
+    from superset.daos.chart import ChartDAO
+
+    expected_ids = {
+        "table": [charts.table_chart_id],
+        "view": [charts.view_chart_id],
+        "both": [charts.table_chart_id, charts.view_chart_id],
+        "orphan": [charts.orphan_chart_id],
+        "none": [],
+    }[expected]
+    with (
+        patch.object(
+            list_charts_module, "user_can_view_data_model_metadata", 
return_value=True
+        ),
+        patch.object(
+            ChartDAO, "_apply_base_filter", side_effect=lambda query, **_: 
query
+        ),
+        patch.object(
+            list_charts_module.event_logger, "log_context", 
return_value=nullcontext()
+        ),
+    ):
+        async with Client(mcp) as client:
+            response = await client.call_tool(
+                "list_charts",
+                {
+                    "request": {
+                        "select_columns": ["id"],
+                        "filters": [
+                            {"col": "datasource_name", "opr": operator, 
"value": value}
+                        ],
+                        "order_column": "id",
+                        "page_size": 1,
+                    }
+                },
+            )
+    result = json.loads(response.content[0].text)
+    assert result["total_count"] == len(expected_ids)
+    assert [chart["id"] for chart in result["charts"]] == expected_ids[:1]
+
+
[email protected]
[email protected]("can_view_data_model", [True, False])
+async def test_default_chart_info_dataset_identifier_visibility(
+    session: Session, charts: PersistedCharts, can_view_data_model: bool
+) -> None:
+    """Default responses include the live identifier only for authorized 
roles."""
+    result = await _call_get_chart_info(
+        session,
+        charts.table_chart_id,
+        can_view_data_model=can_view_data_model,
+        use_defaults=True,
+    )
+    assert result["datasource_id"] == (charts.table_id if can_view_data_model 
else None)
+
+
[email protected]
[email protected]("tool_name", ["get_chart_info", "get_chart_sql"])
+async def test_deleted_dataset_keeps_access_error(
+    session: Session, charts: PersistedCharts, tool_name: str
+) -> None:
+    """Missing datasets keep the pre-existing tool validation error."""
+    from superset.daos.dataset import DatasetDAO
+
+    module = import_module(f"superset.mcp_service.chart.tool.{tool_name}")
+    with (
+        patch.object(module.event_logger, "log_context", 
return_value=nullcontext()),
+        patch.object(
+            get_chart_info_module,
+            "user_can_view_data_model_metadata",
+            return_value=True,
+        ),
+        patch(
+            "superset.daos.chart.ChartDAO.find_by_id",
+            side_effect=_find_by_id(session),
+        ),
+        patch.object(
+            DatasetDAO, "_apply_base_filter", side_effect=lambda query, *_, 
**__: query
+        ),
+    ):
+        async with Client(mcp) as client:
+            response = await client.call_tool(
+                tool_name, {"request": {"identifier": charts.orphan_chart_id}}
+            )
+    result = json.loads(response.content[0].text)
+    assert result["error_type"] == "DatasetNotAccessible"
+    assert "has been deleted or does not exist" in result["error"]
+
+
[email protected]("schema", [None, "", "analytics"])
[email protected]("catalog", [None, "warehouse"])
+def test_live_name_filter_matches_sqla_table_name(
+    session: Session,
+    charts: PersistedCharts,
+    schema: str | None,
+    catalog: str | None,
+) -> None:
+    """Keep the SQL filter in parity with the Python datasource naming rule."""
+    from superset.mcp_service.chart.schemas import ChartFilter
+
+    table = session.query(SqlaTable).filter(SqlaTable.id == 
charts.table_id).one()
+    table.schema = schema
+    table.catalog = catalog
+    session.flush()
+    chart = _load(session, charts.table_chart_id)
+    expected_name = table.name
+    assert resolve_chart_datasource_name(chart) == expected_name
+
+    name_filter = list_charts_module._LiveDatasourceNameFilter(
+        [ChartFilter(col="datasource_name", opr="eq", value=expected_name)]
+    )
+    matches = name_filter.apply(session.query(Slice), None).all()
+    assert [match.id for match in matches] == [charts.table_chart_id]
diff --git a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py 
b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
index 47e642e2673..06bde86dfc4 100644
--- a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
+++ b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
@@ -269,6 +269,8 @@ class TestSerializeDashboardObject:
         chart.id = 5
         chart.slice_name = "Revenue Chart"
         chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_type = "table"
+        chart.datasource_id = 3
         chart.datasource_name = "sales"
         chart.description = "Monthly revenue"
 
@@ -279,6 +281,8 @@ class TestSerializeDashboardObject:
         assert result.charts[0].id == 5
         assert result.charts[0].slice_name == _wrapped("Revenue Chart")
         assert result.charts[0].viz_type == "echarts_timeseries_bar"
+        assert result.charts[0].datasource_id == 3
+        assert result.charts[0].datasource_type == "table"
         assert result.charts[0].datasource_name == "sales"
         assert result.charts[0].url == 
"http://localhost:8088/explore/?slice_id=5";
         # Verify no heavy fields
@@ -300,6 +304,8 @@ class TestSerializeDashboardObject:
         chart.id = 5
         chart.slice_name = "Revenue Chart"
         chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_type = "table"
+        chart.datasource_id = 3
         chart.datasource_name = "sales"
         chart.description = "Monthly revenue"
 
@@ -309,6 +315,8 @@ class TestSerializeDashboardObject:
         assert len(result.charts) == 1
         assert result.charts[0].slice_name == _wrapped("Revenue Chart")
         assert result.charts[0].viz_type == "echarts_timeseries_bar"
+        assert result.charts[0].datasource_id is None
+        assert result.charts[0].datasource_type is None
         assert result.charts[0].datasource_name is None
         assert result.charts[0].url == 
"http://localhost:8088/explore/?slice_id=5";
 
@@ -326,6 +334,8 @@ class TestSerializeDashboardObject:
         chart.id = 5
         chart.slice_name = "Revenue Chart"
         chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_type = "table"
+        chart.datasource_id = 3
         chart.datasource_name = "sales"
         chart.description = "Monthly revenue"
 
@@ -348,6 +358,8 @@ class TestSerializeDashboardObject:
 
         result = dashboard_serializer(dashboard)
 
+        assert result.charts[0].datasource_id is None
+        assert result.charts[0].datasource_type is None
         assert result.charts[0].datasource_name is None
         assert result.native_filters[0].targets == []
 
@@ -366,6 +378,8 @@ class TestSerializeDashboardObject:
         chart.id = 5
         chart.slice_name = "Revenue Chart"
         chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_type = "table"
+        chart.datasource_id = 3
         chart.datasource_name = "sales"
         chart.description = "Monthly revenue"
 
@@ -523,12 +537,16 @@ class TestSerializeChartSummary:
         chart.id = 5
         chart.slice_name = "Revenue Chart"
         chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_type = "table"
+        chart.datasource_id = 3
         chart.datasource_name = "sales"
         chart.description = "Monthly revenue"
 
         result = serialize_chart_summary(chart)
 
         assert result is not None
+        assert result.datasource_id is None
+        assert result.datasource_type is None
         assert result.datasource_name is None
 
 

Reply via email to