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/06/30 22:27:15 UTC
(superset) branch master updated: feat(mcp): add get_dashboard_datasets tool (#40961)
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 fd9c84be430 feat(mcp): add get_dashboard_datasets tool (#40961)
fd9c84be430 is described below
commit fd9c84be43015ebd3b2f76028b306484434584f8
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Tue Jun 30 18:27:09 2026 -0400
feat(mcp): add get_dashboard_datasets tool (#40961)
---
superset/mcp_service/app.py | 2 +
superset/mcp_service/dashboard/schemas.py | 250 ++++++++++++-
superset/mcp_service/dashboard/tool/__init__.py | 2 +
.../dashboard/tool/get_dashboard_datasets.py | 156 +++++++++
.../dashboard/tool/test_get_dashboard_datasets.py | 388 +++++++++++++++++++++
5 files changed, 796 insertions(+), 2 deletions(-)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index b9852b2e9b7..fdadd29fd58 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -128,6 +128,7 @@ Dashboard Management:
- list_dashboards: List dashboards with advanced filters (1-based pagination)
- get_dashboard_info: Get detailed dashboard information by ID
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
+- get_dashboard_datasets: List the datasets used by a dashboard's charts, with columns and metrics (context for configuring native filters)
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
- update_dashboard: Update an existing dashboard's title/description/slug/published/layout/theme/CSS (requires write access; ownership-checked per-instance)
- duplicate_dashboard: Duplicate an existing dashboard, optionally deep-copying its charts (requires write access)
@@ -698,6 +699,7 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
duplicate_dashboard,
generate_dashboard,
+ get_dashboard_datasets,
get_dashboard_info,
get_dashboard_layout,
list_dashboards,
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index 12fb36066ea..d579bba42a5 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -67,7 +67,7 @@ from __future__ import annotations
import logging
import re
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Annotated, Any, cast, Dict, List, Literal, TYPE_CHECKING
from pydantic import (
@@ -134,7 +134,11 @@ class DashboardError(BaseModel):
@classmethod
def create(cls, error: str, error_type: str) -> "DashboardError":
"""Create a standardized DashboardError with timestamp."""
- return cls(error=error, error_type=error_type, timestamp=datetime.now())
+ return cls(
+ error=error,
+ error_type=error_type,
+ timestamp=datetime.now(timezone.utc),
+ )
def serialize_tag_object(tag: Any) -> TagInfo | None:
@@ -378,6 +382,17 @@ class GetDashboardLayoutRequest(BaseModel):
]
+class GetDashboardDatasetsRequest(BaseModel):
+ """Request schema for get_dashboard_datasets."""
+
+ identifier: Annotated[
+ int | str,
+ Field(
+ description="Dashboard identifier - can be numeric ID, UUID string, or slug"
+ ),
+ ]
+
+
logger = logging.getLogger(__name__)
@@ -1983,3 +1998,234 @@ class ManageNativeFiltersResponse(BaseModel):
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("error",))
+
+
+# ---------------------------------------------------------------------------
+# get_dashboard_datasets schemas
+# ---------------------------------------------------------------------------
+
+# Per-dataset caps keep responses small enough for LLM context: wide
+# datasets can have hundreds of columns, which would dwarf the fields an
+# agent actually needs to configure native filters.
+MAX_DASHBOARD_DATASET_COLUMNS: int = 100
+MAX_DASHBOARD_DATASET_METRICS: int = 50
+
+
+class DashboardDatasetColumn(BaseModel):
+ """Lean column representation for dashboard dataset context."""
+
+ column_name: str = Field(..., description="Column name")
+ verbose_name: str | None = Field(None, description="Verbose (display) name")
+ type: str | None = Field(None, description="Column data type")
+ is_dttm: bool | None = Field(None, description="Is datetime column")
+
+
+class DashboardDatasetMetric(BaseModel):
+ """Lean metric representation for dashboard dataset context."""
+
+ metric_name: str = Field(..., description="Saved metric name")
+ verbose_name: str | None = Field(None, description="Verbose (display) name")
+ expression: str | None = Field(None, description="SQL expression")
+
+
+class DashboardDatasetDatabaseInfo(BaseModel):
+ """Database connection summary for a dashboard dataset."""
+
+ id: int | None = Field(None, description="Database ID")
+ name: str | None = Field(None, description="Database name")
+ backend: str | None = Field(None, description="Database backend (engine)")
+
+
+class DashboardDatasetSummary(BaseModel):
+ """A dataset used by a dashboard's charts, with columns and metrics."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ id: int | None = Field(None, description="Dataset ID")
+ uuid: str | None = Field(None, description="Dataset UUID")
+ table_name: str | None = Field(None, description="Table name")
+ schema_name: str | None = Field(None, description="Schema name", alias="schema")
+ database: DashboardDatasetDatabaseInfo | None = Field(
+ None, description="Database the dataset belongs to"
+ )
+ chart_count: int = Field(
+ 0, description="Number of charts on the dashboard using this dataset"
+ )
+ columns: List[DashboardDatasetColumn] = Field(
+ default_factory=list, description="Dataset columns"
+ )
+ metrics: List[DashboardDatasetMetric] = Field(
+ default_factory=list, description="Dataset metrics"
+ )
+ total_column_count: int = Field(
+ 0, description="Total number of columns on the dataset"
+ )
+ total_metric_count: int = Field(
+ 0, description="Total number of metrics on the dataset"
+ )
+ columns_truncated: bool = Field(
+ False,
+ description=(
+ "True when the columns list was truncated to keep the response small"
+ ),
+ )
+ metrics_truncated: bool = Field(
+ False,
+ description=(
+ "True when the metrics list was truncated to keep the response small"
+ ),
+ )
+
+ @model_serializer(mode="wrap")
+ def _rename_schema_field(self, serializer: Any, info: Any) -> Dict[str, Any]:
+ """Serialize 'schema_name' as 'schema' to match API conventions."""
+ data = serializer(self)
+ if "schema_name" in data:
+ data["schema"] = data.pop("schema_name")
+ return data
+
+
+class DashboardDatasets(BaseModel):
+ """Response schema for get_dashboard_datasets."""
+
+ id: int | None = Field(None, description="Dashboard ID")
+ dashboard_title: str | None = Field(None, description="Dashboard title")
+ uuid: str | None = Field(None, description="Dashboard UUID")
+ dataset_count: int = Field(
+ 0, description="Number of accessible datasets used by the dashboard"
+ )
+ inaccessible_dataset_count: int = Field(
+ 0,
+ description=(
+ "Number of datasets used by the dashboard that the current user "
+ "cannot access (excluded from 'datasets')"
+ ),
+ )
+ datasets: List[DashboardDatasetSummary] = Field(
+ default_factory=list,
+ description="Datasets used by the dashboard's charts",
+ )
+
+
+def _serialize_dashboard_dataset(
+ datasource: Any, chart_count: int
+) -> DashboardDatasetSummary:
+ """Serialize a datasource to a lean, LLM-safe dataset summary."""
+ all_columns = list(getattr(datasource, "columns", None) or [])
+ all_metrics = list(getattr(datasource, "metrics", None) or [])
+
+ columns = [
+ DashboardDatasetColumn(
+ column_name=escape_llm_context_delimiters(
+ getattr(column, "column_name", None) or ""
+ ),
+ verbose_name=sanitize_for_llm_context(
+ getattr(column, "verbose_name", None),
+ field_path=("columns", str(index), "verbose_name"),
+ ),
+ type=getattr(column, "type", None),
+ is_dttm=getattr(column, "is_dttm", None),
+ )
+ for index, column in enumerate(all_columns[:MAX_DASHBOARD_DATASET_COLUMNS])
+ ]
+ metrics = [
+ DashboardDatasetMetric(
+ metric_name=escape_llm_context_delimiters(
+ getattr(metric, "metric_name", None) or ""
+ ),
+ verbose_name=sanitize_for_llm_context(
+ getattr(metric, "verbose_name", None),
+ field_path=("metrics", str(index), "verbose_name"),
+ ),
+ expression=sanitize_for_llm_context(
+ getattr(metric, "expression", None),
+ field_path=("metrics", str(index), "expression"),
+ ),
+ )
+ for index, metric in enumerate(all_metrics[:MAX_DASHBOARD_DATASET_METRICS])
+ ]
+
+ database = getattr(datasource, "database", None)
+ database_info = (
+ DashboardDatasetDatabaseInfo(
+ id=getattr(database, "id", None),
+ name=escape_llm_context_delimiters(
+ getattr(database, "database_name", None)
+ ),
+ backend=getattr(database, "backend", None),
+ )
+ if database is not None
+ else None
+ )
+
+ dataset_uuid = getattr(datasource, "uuid", None)
+ return DashboardDatasetSummary(
+ id=getattr(datasource, "id", None),
+ uuid=str(dataset_uuid) if dataset_uuid else None,
+ table_name=escape_llm_context_delimiters(
+ getattr(datasource, "table_name", None)
+ ),
+ schema_name=escape_llm_context_delimiters(getattr(datasource, "schema", None)),
+ database=database_info,
+ chart_count=chart_count,
+ columns=columns,
+ metrics=metrics,
+ total_column_count=len(all_columns),
+ total_metric_count=len(all_metrics),
+ columns_truncated=len(all_columns) > MAX_DASHBOARD_DATASET_COLUMNS,
+ metrics_truncated=len(all_metrics) > MAX_DASHBOARD_DATASET_METRICS,
+ )
+
+
+def dashboard_datasets_serializer(dashboard: "Dashboard") -> DashboardDatasets:
+ """Serialize a Dashboard model to the datasets used by its charts.
+
+ Groups the dashboard's charts by datasource (mirroring
+ ``Dashboard.datasets_trimmed_for_slices``) but keeps the full column and
+ metric lists (capped) since native-filter configuration regularly needs
+ columns that no chart references. Datasets the current user cannot
+ access are excluded and only counted.
+ """
+ from superset.mcp_service.auth import has_dataset_access
+
+ slices_by_datasource: Dict[tuple[int, str], List[Any]] = {}
+ for slc in getattr(dashboard, "slices", None) or []:
+ datasource_id = getattr(slc, "datasource_id", None)
+ datasource_type = getattr(slc, "datasource_type", None) or ""
+ if datasource_id is None:
+ continue
+ slices_by_datasource.setdefault((datasource_id, datasource_type), []).append(
+ slc
+ )
+
+ datasets: List[DashboardDatasetSummary] = []
+ inaccessible_count: int = 0
+ for slices in slices_by_datasource.values():
+ datasource = next(
+ (
+ getattr(slc, "datasource", None)
+ for slc in slices
+ if getattr(slc, "datasource", None) is not None
+ ),
+ None,
+ )
+ if datasource is None:
+ continue
+ if not has_dataset_access(datasource):
+ inaccessible_count += 1
+ continue
+ datasets.append(_serialize_dashboard_dataset(datasource, len(slices)))
+
+ datasets.sort(key=lambda dataset: dataset.id or 0)
+
+ return DashboardDatasets(
+ id=dashboard.id,
+ dashboard_title=sanitize_for_llm_context(
+ dashboard.dashboard_title or "Untitled",
+ field_path=("dashboard_title",),
+ ),
+ uuid=str(dashboard.uuid) if dashboard.uuid else None,
+ dataset_count=len(datasets),
+ inaccessible_dataset_count=inaccessible_count,
+ datasets=datasets,
+ )
diff --git a/superset/mcp_service/dashboard/tool/__init__.py b/superset/mcp_service/dashboard/tool/__init__.py
index ab41219bdfa..49a61cca85e 100644
--- a/superset/mcp_service/dashboard/tool/__init__.py
+++ b/superset/mcp_service/dashboard/tool/__init__.py
@@ -18,6 +18,7 @@
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .duplicate_dashboard import duplicate_dashboard
from .generate_dashboard import generate_dashboard
+from .get_dashboard_datasets import get_dashboard_datasets
from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
from .list_dashboards import list_dashboards
@@ -27,6 +28,7 @@ from .update_dashboard import update_dashboard
__all__ = [
"list_dashboards",
+ "get_dashboard_datasets",
"get_dashboard_info",
"get_dashboard_layout",
"generate_dashboard",
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py b/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py
new file mode 100644
index 00000000000..293b8b683e7
--- /dev/null
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py
@@ -0,0 +1,156 @@
+# 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.
+
+"""
+Get dashboard datasets FastMCP tool
+
+Returns the datasets used by a dashboard's charts, including columns and
+metrics. This is the prerequisite context an agent needs before configuring
+native filters on a dashboard (e.g. picking filter target columns).
+"""
+
+import logging
+from datetime import datetime, timezone
+
+from fastmcp import Context
+from sqlalchemy.orm import subqueryload
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.dashboard.schemas import (
+ dashboard_datasets_serializer,
+ DashboardDatasets,
+ DashboardError,
+ GetDashboardDatasetsRequest,
+)
+from superset.mcp_service.mcp_core import ModelGetInfoCore
+from superset.mcp_service.privacy import (
+ DATA_MODEL_METADATA_ERROR_TYPE,
+ requires_data_model_metadata_access,
+ user_can_view_data_model_metadata,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+ tags=["core"],
+ class_permission_name="Dashboard",
+ annotations=ToolAnnotations(
+ title="Get dashboard datasets",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+@requires_data_model_metadata_access
+async def get_dashboard_datasets(
+ request: GetDashboardDatasetsRequest, ctx: Context
+) -> DashboardDatasets | DashboardError:
+ """
+ List the datasets used by a dashboard's charts, by ID, UUID, or slug.
+
+ Each dataset includes its table name, schema, database connection
+ (id, name, backend), columns (name, type, is_dttm, verbose_name) and
+ metrics (name, expression, verbose_name). Use this to understand which
+ columns and metrics are available before configuring native filters or
+ analyzing a dashboard's data model.
+
+ Datasets the current user cannot access are excluded from the response
+ and reported via inaccessible_dataset_count. Column and metric lists are
+ capped per dataset; when truncated, columns_truncated/metrics_truncated
+ are set and total counts are reported.
+
+ Requires data-model metadata permission (same as the dataset tools); a
+ dashboard-only viewer without that permission receives a structured
+ privacy denial.
+
+ Example usage:
+ ```json
+ {
+ "identifier": 123
+ }
+ ```
+ """
+ await ctx.info(
+ "Retrieving dashboard datasets: identifier=%s" % (request.identifier,)
+ )
+
+ # The decorator hides this tool from search; this check enforces direct
+ # calls so dashboard-only viewers can't read dataset/database metadata.
+ if not user_can_view_data_model_metadata():
+ await ctx.warning("Dashboard datasets lookup blocked by privacy controls")
+ return DashboardError.create(
+ error="You don't have permission to access dataset details for your role.",
+ error_type=DATA_MODEL_METADATA_ERROR_TYPE,
+ )
+
+ try:
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.daos.dashboard import DashboardDAO
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+
+ # Eager load slices and each slice's dataset columns/metrics/database to
+ # avoid N+1 queries: the serializer groups slices by datasource and reads
+ # columns, metrics, and database off every dataset.
+ slice_dataset = subqueryload(Dashboard.slices).subqueryload(Slice.table)
+ eager_options = [
+ slice_dataset.subqueryload(SqlaTable.columns),
+ slice_dataset.subqueryload(SqlaTable.metrics),
+ slice_dataset.joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.get_dashboard_datasets.lookup"):
+ core = ModelGetInfoCore(
+ dao_class=DashboardDAO,
+ output_schema=DashboardDatasets,
+ error_schema=DashboardError,
+ serializer=dashboard_datasets_serializer,
+ supports_slug=True,
+ logger=logger,
+ query_options=eager_options,
+ )
+ result = core.run_tool(request.identifier)
+
+ if isinstance(result, DashboardDatasets):
+ await ctx.info(
+ "Dashboard datasets retrieved: id=%s, dataset_count=%s, "
+ "inaccessible_dataset_count=%s"
+ % (
+ result.id,
+ result.dataset_count,
+ result.inaccessible_dataset_count,
+ )
+ )
+ else:
+ await ctx.warning(
+ "Dashboard datasets retrieval failed: error_type=%s, error=%s"
+ % (result.error_type, result.error)
+ )
+
+ return result
+
+ except Exception as e:
+ await ctx.error(
+ "Dashboard datasets retrieval failed: identifier=%s, error=%s, "
+ "error_type=%s" % (request.identifier, str(e), type(e).__name__)
+ )
+ return DashboardError(
+ error=f"Failed to get dashboard datasets: {str(e)}",
+ error_type="InternalError",
+ timestamp=datetime.now(timezone.utc),
+ )
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_datasets.py b/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_datasets.py
new file mode 100644
index 00000000000..10ce19b3112
--- /dev/null
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_datasets.py
@@ -0,0 +1,388 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for the MCP get_dashboard_datasets tool."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.utils.sanitization import (
+ LLM_CONTEXT_CLOSE_DELIMITER,
+ LLM_CONTEXT_OPEN_DELIMITER,
+)
+from superset.utils import json
+
+
+def _wrapped(value: str) -> str:
+ return f"{LLM_CONTEXT_OPEN_DELIMITER}\n{value}\n{LLM_CONTEXT_CLOSE_DELIMITER}"
+
+
+def _build_column_mock(
+ name: str,
+ *,
+ verbose_name: str | None = None,
+ type_: str | None = "VARCHAR",
+ is_dttm: bool = False,
+) -> Mock:
+ column = Mock()
+ column.column_name = name
+ column.verbose_name = verbose_name
+ column.type = type_
+ column.is_dttm = is_dttm
+ return column
+
+
+def _build_metric_mock(
+ name: str,
+ *,
+ verbose_name: str | None = None,
+ expression: str | None = None,
+) -> Mock:
+ metric = Mock()
+ metric.metric_name = name
+ metric.verbose_name = verbose_name
+ metric.expression = expression
+ return metric
+
+
+def _build_database_mock(
+ *, database_id: int = 7, name: str = "examples", backend: str = "postgresql"
+) -> Mock:
+ database = Mock()
+ database.id = database_id
+ database.database_name = name
+ database.backend = backend
+ return database
+
+
+def _build_datasource_mock(
+ *,
+ dataset_id: int,
+ uuid: str | None = None,
+ table_name: str = "my_table",
+ schema: str | None = "public",
+ database: Mock | None = None,
+ columns: list[Mock] | None = None,
+ metrics: list[Mock] | None = None,
+) -> Mock:
+ datasource = Mock()
+ datasource.id = dataset_id
+ datasource.uuid = uuid
+ datasource.table_name = table_name
+ datasource.schema = schema
+ datasource.database = database
+ datasource.columns = columns or []
+ datasource.metrics = metrics or []
+ return datasource
+
+
+def _build_slice_mock(datasource: Mock, datasource_type: str = "table") -> Mock:
+ slc = Mock()
+ slc.datasource_id = datasource.id
+ slc.datasource_type = datasource_type
+ slc.datasource = datasource
+ return slc
+
+
+def _build_dashboard_mock(
+ *,
+ dashboard_id: int = 1,
+ title: str = "Test Dashboard",
+ uuid: str | None = "dashboard-uuid-1",
+ slices: list[Mock] | None = None,
+) -> Mock:
+ dashboard = Mock()
+ dashboard.id = dashboard_id
+ dashboard.dashboard_title = title
+ dashboard.uuid = uuid
+ dashboard.slices = slices or []
+ return dashboard
+
+
+@pytest.fixture
+def mcp_server():
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth():
+ 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 mock_get_user
+
+
+@pytest.fixture(autouse=True)
+def mock_dataset_access():
+ with patch(
+ "superset.mcp_service.auth.has_dataset_access", return_value=True
+ ) as mock_access:
+ yield mock_access
+
+
+@pytest.fixture(autouse=True)
+def allow_data_model_metadata():
+ """Keep tests in the metadata-allowed path unless a test overrides it."""
+ with patch(
+ "superset.mcp_service.dashboard.tool.get_dashboard_datasets."
+ "user_can_view_data_model_metadata",
+ return_value=True,
+ ) as mock_allow:
+ yield mock_allow
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_multiple_datasets(mock_find, mcp_server):
+ sales = _build_datasource_mock(
+ dataset_id=10,
+ uuid="dataset-uuid-10",
+ table_name="sales",
+ schema="public",
+ database=_build_database_mock(),
+ columns=[
+ _build_column_mock("region", verbose_name="Region"),
+ _build_column_mock("order_date", type_="TIMESTAMP", is_dttm=True),
+ ],
+ metrics=[
+ _build_metric_mock(
+ "total_revenue",
+ verbose_name="Total Revenue",
+ expression="SUM(revenue)",
+ )
+ ],
+ )
+ customers = _build_datasource_mock(
+ dataset_id=20,
+ uuid="dataset-uuid-20",
+ table_name="customers",
+ schema="crm",
+ database=_build_database_mock(database_id=8, name="crm_db", backend="mysql"),
+ columns=[_build_column_mock("customer_name")],
+ metrics=[],
+ )
+ mock_find.return_value = _build_dashboard_mock(
+ slices=[
+ _build_slice_mock(sales),
+ _build_slice_mock(sales),
+ _build_slice_mock(customers),
+ ]
+ )
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": 1}}
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["id"] == 1
+ assert data["dashboard_title"] == _wrapped("Test Dashboard")
+ assert data["uuid"] == "dashboard-uuid-1"
+ assert data["dataset_count"] == 2
+ assert data["inaccessible_dataset_count"] == 0
+ assert len(data["datasets"]) == 2
+
+ datasets_by_id = {d["id"]: d for d in data["datasets"]}
+ sales_data = datasets_by_id[10]
+ assert sales_data["uuid"] == "dataset-uuid-10"
+ assert sales_data["table_name"] == "sales"
+ assert sales_data["schema"] == "public"
+ assert sales_data["database"] == {
+ "id": 7,
+ "name": "examples",
+ "backend": "postgresql",
+ }
+ assert sales_data["chart_count"] == 2
+ assert sales_data["columns"] == [
+ {
+ "column_name": "region",
+ "verbose_name": _wrapped("Region"),
+ "type": "VARCHAR",
+ "is_dttm": False,
+ },
+ {
+ "column_name": "order_date",
+ "verbose_name": None,
+ "type": "TIMESTAMP",
+ "is_dttm": True,
+ },
+ ]
+ assert sales_data["metrics"] == [
+ {
+ "metric_name": "total_revenue",
+ "verbose_name": _wrapped("Total Revenue"),
+ "expression": _wrapped("SUM(revenue)"),
+ }
+ ]
+ assert sales_data["total_column_count"] == 2
+ assert sales_data["total_metric_count"] == 1
+ assert sales_data["columns_truncated"] is False
+ assert sales_data["metrics_truncated"] is False
+
+ customers_data = datasets_by_id[20]
+ assert customers_data["table_name"] == "customers"
+ assert customers_data["schema"] == "crm"
+ assert customers_data["chart_count"] == 1
+ assert customers_data["metrics"] == []
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_by_slug(mock_find, mcp_server):
+ datasource = _build_datasource_mock(
+ dataset_id=10,
+ table_name="sales",
+ database=_build_database_mock(),
+ columns=[_build_column_mock("region")],
+ )
+ dashboard = _build_dashboard_mock(slices=[_build_slice_mock(datasource)])
+
+ def find_by_id(identifier, id_column=None, query_options=None):
+ if id_column == "slug" and identifier == "sales-dash":
+ return dashboard
+ return None
+
+ mock_find.side_effect = find_by_id
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": "sales-dash"}}
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["id"] == 1
+ assert data["dataset_count"] == 1
+ assert data["datasets"][0]["table_name"] == "sales"
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_not_found(mock_find, mcp_server):
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": 999}}
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["error_type"] == "not_found"
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_metadata_restricted(
+ mock_find, mcp_server, allow_data_model_metadata
+):
+ """Users without data-model metadata permission get a structured denial."""
+ from superset.mcp_service.privacy import DATA_MODEL_METADATA_ERROR_TYPE
+
+ allow_data_model_metadata.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": 1}}
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["error_type"] == DATA_MODEL_METADATA_ERROR_TYPE
+ # The privacy gate short-circuits before any dashboard lookup.
+ mock_find.assert_not_called()
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_empty_dashboard(mock_find, mcp_server):
+ mock_find.return_value = _build_dashboard_mock(slices=[])
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": 1}}
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["id"] == 1
+ assert data["dataset_count"] == 0
+ assert data["inaccessible_dataset_count"] == 0
+ assert data["datasets"] == []
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_excludes_inaccessible(
+ mock_find, mcp_server, mock_dataset_access
+):
+ allowed = _build_datasource_mock(dataset_id=10, table_name="sales")
+ denied = _build_datasource_mock(dataset_id=20, table_name="secrets")
+ mock_find.return_value = _build_dashboard_mock(
+ slices=[_build_slice_mock(allowed), _build_slice_mock(denied)]
+ )
+ mock_dataset_access.side_effect = lambda datasource: datasource.id != 20
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": 1}}
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["dataset_count"] == 1
+ assert data["inaccessible_dataset_count"] == 1
+ assert [d["id"] for d in data["datasets"]] == [10]
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_datasets_truncates_wide_datasets(mock_find, mcp_server):
+ from superset.mcp_service.dashboard.schemas import (
+ MAX_DASHBOARD_DATASET_COLUMNS,
+ MAX_DASHBOARD_DATASET_METRICS,
+ )
+
+ datasource = _build_datasource_mock(
+ dataset_id=10,
+ table_name="wide_table",
+ columns=[
+ _build_column_mock(f"col_{i}")
+ for i in range(MAX_DASHBOARD_DATASET_COLUMNS + 5)
+ ],
+ metrics=[
+ _build_metric_mock(f"metric_{i}")
+ for i in range(MAX_DASHBOARD_DATASET_METRICS + 3)
+ ],
+ )
+ mock_find.return_value = _build_dashboard_mock(
+ slices=[_build_slice_mock(datasource)]
+ )
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_datasets", {"request": {"identifier": 1}}
+ )
+ data = json.loads(result.content[0].text)
+
+ dataset = data["datasets"][0]
+ assert len(dataset["columns"]) == MAX_DASHBOARD_DATASET_COLUMNS
+ assert len(dataset["metrics"]) == MAX_DASHBOARD_DATASET_METRICS
+ assert dataset["columns_truncated"] is True
+ assert dataset["metrics_truncated"] is True
+ assert dataset["total_column_count"] == MAX_DASHBOARD_DATASET_COLUMNS + 5
+ assert dataset["total_metric_count"] == MAX_DASHBOARD_DATASET_METRICS + 3