You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ga...@apache.org on 2026/09/15 20:20:18 UTC
(superset) branch master updated: feat(mcp): add get_dashboard_data for bounded, filter-aware dashboard data (#44113)
This is an automated email from the ASF dual-hosted git repository.
gabotorresruiz 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 ef4281d2fcb feat(mcp): add get_dashboard_data for bounded, filter-aware dashboard data (#44113)
ef4281d2fcb is described below
commit ef4281d2fcb854334529d9d7c2f6e82301bb7fa8
Author: Gabriel Torres Ruiz <ga...@gmail.com>
AuthorDate: Tue Sep 15 17:20:12 2026 -0300
feat(mcp): add get_dashboard_data for bounded, filter-aware dashboard data (#44113)
---
docs/admin_docs/configuration/mcp-server.mdx | 2 +
.../docs/using-superset/using-ai-with-superset.mdx | 1 +
superset/mcp_service/app.py | 2 +
superset/mcp_service/chart/tool/get_chart_data.py | 14 +-
superset/mcp_service/dashboard/schemas.py | 144 +++++++
superset/mcp_service/dashboard/tool/__init__.py | 2 +
.../dashboard/tool/get_dashboard_data.py | 279 ++++++++++++
superset/mcp_service/mcp_config.py | 1 +
.../dashboard/tool/test_get_dashboard_data.py | 480 +++++++++++++++++++++
9 files changed, 924 insertions(+), 1 deletion(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index c60a546f0b1..cca761e5d49 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -286,6 +286,7 @@ curl -X POST http://localhost:5008/mcp \
MCP_GUEST_ALLOWED_TOOLS = {
"get_dashboard_info",
"get_dashboard_layout",
+ "get_dashboard_data",
"list_dashboards",
"list_charts",
"get_chart_info",
@@ -735,6 +736,7 @@ Each tool declares one or more required FAB permissions. The table below maps to
| -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `list_charts`, `get_chart_info`, `get_chart_data`, `get_chart_preview`, `generate_chart`, `update_chart` | `can_read` on `Chart` (read), `can_write` on `Chart` (mutate) |
| `list_dashboards`, `get_dashboard_info`, `generate_dashboard`, `add_chart_to_existing_dashboard` | `can_read` on `Dashboard` (read), `can_write` on `Dashboard` (mutate) |
+| `get_dashboard_data` | `can_read` on `Dashboard` **and** `can_read` on `Chart` (it reads chart data across the dashboard) |
| `list_datasets`, `get_dataset_info`, `create_virtual_dataset` | `can_read` on `Dataset` (read), `can_write` on `Dataset` (mutate) |
| `list_databases`, `get_database_info` | `can_read` on `Database` |
| `execute_sql` | `can_execute_sql_query` on `SQLLab` |
diff --git a/docs/docs/using-superset/using-ai-with-superset.mdx b/docs/docs/using-superset/using-ai-with-superset.mdx
index 4ded14cf020..31132143cfc 100644
--- a/docs/docs/using-superset/using-ai-with-superset.mdx
+++ b/docs/docs/using-superset/using-ai-with-superset.mdx
@@ -288,6 +288,7 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `list_dashboards` | List dashboards with filtering and search |
| `get_dashboard_info` | Get dashboard metadata and layout |
+| `get_dashboard_data` | Get bounded, filter-aware data across a dashboard's charts in one call |
| `generate_dashboard` | Create a new dashboard with specified charts |
| `add_chart_to_existing_dashboard` | Add a chart to an existing dashboard |
| `delete_dashboard` | Delete a dashboard by ID, UUID, or slug; leaves its charts intact (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the dashboard — owners, Admins, and explicitly granted editors qualify) |
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index d90ae7b03aa..81cdaabcb47 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -128,6 +128,7 @@ Dashboard Management:
- get_dashboard_info: Resolve a dashboard by ID/UUID/slug or shared /dashboard/p/<key>/ permalink, including its active-tab and filter state
- get_dashboard_layout: Get parsed tabs and chart positions by dashboard identifier or shared permalink, including the permalink's active-tab and filter context
- get_dashboard_datasets: List the datasets used by a dashboard's charts, with columns and metrics (context for configuring native filters)
+- get_dashboard_data: Get bounded, filter-aware data across a dashboard's charts in one call (compact per-chart columns, sample rows, and row counts, selected in layout order) for analytical questions about a whole dashboard
- 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; editorship-checked per-instance)
- duplicate_dashboard: Duplicate an existing dashboard, optionally deep-copying its charts (requires write access)
@@ -801,6 +802,7 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
delete_dashboard,
duplicate_dashboard,
generate_dashboard,
+ get_dashboard_data,
get_dashboard_datasets,
get_dashboard_info,
get_dashboard_layout,
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index 511e6c29d9b..8448a79b9f7 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -305,7 +305,7 @@ def _build_query_results(
openWorldHint=False,
),
)
-async def get_chart_data( # noqa: C901
+async def get_chart_data(
request: GetChartDataRequest, ctx: Context
) -> ChartData | ChartError:
"""Get chart data by ID or UUID.
@@ -325,6 +325,18 @@ async def get_chart_data( # noqa: C901
Returns underlying data in requested format with cache status.
"""
+ return await execute_chart_data(request, ctx)
+
+
+async def execute_chart_data( # noqa: C901
+ request: GetChartDataRequest, ctx: Context
+) -> ChartData | ChartError:
+ """Shared core behind get_chart_data.
+
+ Undecorated entry point so other tools (e.g. get_dashboard_data) reuse the
+ same query and guest-authorization path without re-entering the auth-wrapped
+ tool.
+ """
await ctx.info(
"Starting chart data retrieval: identifier=%s, format=%s, limit=%s, "
"form_data_key=%s"
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index 4ee2b3cb91e..e022c87a527 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -2462,3 +2462,147 @@ class RestoreDashboardResponse(BaseModel):
"not retry; ask the user)."
),
)
+
+
+class GetDashboardDataRequest(BaseModel):
+ """Request schema for get_dashboard_data."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ identifier: int | str = Field(
+ ...,
+ description="Dashboard identifier - numeric ID, UUID string, or slug.",
+ validation_alias=AliasChoices("identifier", "id", "dashboard_id"),
+ )
+ applied_filters: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "Active dashboard filters to apply, keyed by chart id. Each value is "
+ "extra_form_data merged into that chart's query, e.g. "
+ '{"56": {"filters": [{"col": "country", "op": "IN", "val": ["US"]}]}}. '
+ "Charts without an entry are queried unfiltered."
+ ),
+ )
+ max_charts: int = Field(
+ default=15,
+ ge=1,
+ le=30,
+ description=(
+ "Maximum number of charts to fetch data for, selected in dashboard "
+ "layout (reading) order so the most prominent charts are covered "
+ "first. If the dashboard has more, the response is truncated and "
+ "charts_truncated is set."
+ ),
+ )
+ fetch_row_limit: int = Field(
+ default=100,
+ ge=1,
+ le=1000,
+ description=(
+ "Rows to query per chart. Only sample_rows of these are returned "
+ "per chart; total_rows reports the full count when the fetch was not "
+ "capped, and the per-chart truncated flag marks a capped fetch."
+ ),
+ )
+ sample_rows: int = Field(
+ default=10,
+ ge=0,
+ le=20,
+ description="Number of example data rows to include per chart.",
+ )
+ time_budget_seconds: float = Field(
+ default=25.0,
+ gt=0,
+ le=60,
+ description=(
+ "Soft wall-clock budget for fetching charts. When reached, returns "
+ "the charts gathered so far (in layout order) and sets "
+ "charts_truncated; at least one chart is always attempted."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dashboard ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DashboardChartQueryData(BaseModel):
+ """One query layer's data for a multi-query chart (e.g. Mixed Timeseries)."""
+
+ query_index: int = Field(..., description="Zero-based query position")
+ columns: list[str] = Field(default_factory=list, description="Result column names")
+ sample_data: list[dict[str, Any]] = Field(
+ default_factory=list, description="A few example data rows"
+ )
+ row_count: int | None = Field(None, description="Rows returned by this query")
+ total_rows: int | None = Field(
+ None, description="Total rows available for this query when known"
+ )
+ truncated: bool = Field(
+ False, description="True when this query returned fewer than the total"
+ )
+
+
+class DashboardChartData(BaseModel):
+ """Compact per-chart data summary for a dashboard-wide insights payload."""
+
+ chart_id: int = Field(..., description="Chart id")
+ chart_name: str = Field(..., description="Chart name")
+ chart_type: str = Field(..., description="Chart viz type")
+ columns: list[str] = Field(default_factory=list, description="Result column names")
+ sample_data: list[dict[str, Any]] = Field(
+ default_factory=list, description="A few example data rows"
+ )
+ row_count: int | None = Field(None, description="Rows returned by the query")
+ total_rows: int | None = Field(
+ None,
+ description=(
+ "Total rows available when known; null when the fetch was capped with "
+ "no authoritative total (see truncated)"
+ ),
+ )
+ truncated: bool = Field(
+ False,
+ description="True when fewer rows were returned than the total available",
+ )
+ queries: list[DashboardChartQueryData] | None = Field(
+ None,
+ description="Per-query layers for multi-query charts; null for single-query",
+ )
+ filtered: bool = Field(
+ False, description="Whether active filters were applied to this chart"
+ )
+ error: str | None = Field(
+ None, description="Set when this chart's data could not be retrieved"
+ )
+
+
+class DashboardData(BaseModel):
+ """Bounded, filter-aware data across a dashboard's charts.
+
+ Assembles a compact view of each chart's underlying data (columns, a few
+ sample rows, and row counts), selected in dashboard layout order, so an
+ agent can answer analytical questions about the whole dashboard from a
+ single call, instead of fetching each chart separately.
+ """
+
+ dashboard_id: int = Field(..., description="Dashboard id")
+ dashboard_name: str = Field(..., description="Dashboard title")
+ chart_count: int = Field(..., description="Total number of charts on the dashboard")
+ charts_returned: int = Field(
+ ..., description="Number of charts included in this response"
+ )
+ charts_truncated: bool = Field(
+ False,
+ description="True when the dashboard has more charts than max_charts",
+ )
+ charts: list[DashboardChartData] = Field(
+ default_factory=list, description="Per-chart data summaries"
+ )
+ schema_version: str = Field("1.0", description="Response schema version")
+ api_version: str = Field("v1", description="MCP API version")
diff --git a/superset/mcp_service/dashboard/tool/__init__.py b/superset/mcp_service/dashboard/tool/__init__.py
index ae6cf5c44cf..8c5ea172085 100644
--- a/superset/mcp_service/dashboard/tool/__init__.py
+++ b/superset/mcp_service/dashboard/tool/__init__.py
@@ -19,6 +19,7 @@ from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .delete_dashboard import delete_dashboard
from .duplicate_dashboard import duplicate_dashboard
from .generate_dashboard import generate_dashboard
+from .get_dashboard_data import get_dashboard_data
from .get_dashboard_datasets import get_dashboard_datasets
from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
@@ -33,6 +34,7 @@ from .update_dashboard import update_dashboard
__all__ = [
"list_dashboards",
+ "get_dashboard_data",
"get_dashboard_datasets",
"get_dashboard_info",
"get_dashboard_layout",
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_data.py b/superset/mcp_service/dashboard/tool/get_dashboard_data.py
new file mode 100644
index 00000000000..3cc0e916f25
--- /dev/null
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_data.py
@@ -0,0 +1,279 @@
+# 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 data FastMCP tool
+
+Returns a compact, filter-aware summary of the underlying data across a
+dashboard's charts, so an agent can answer analytical questions about the whole
+dashboard from a single call instead of fetching each chart separately.
+"""
+
+import logging
+from datetime import datetime, timezone
+from time import monotonic
+from typing import Any, TYPE_CHECKING
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.chart.schemas import (
+ ChartData,
+ ChartError,
+ GetChartDataRequest,
+)
+from superset.mcp_service.chart.tool.get_chart_data import (
+ execute_chart_data,
+ get_chart_data,
+)
+from superset.mcp_service.dashboard.schemas import (
+ _extract_layout_from_position,
+ DashboardChartData,
+ DashboardChartQueryData,
+ DashboardData,
+ DashboardError,
+ GetDashboardDataRequest,
+)
+
+if TYPE_CHECKING:
+ from superset.models.slice import Slice
+
+logger = logging.getLogger(__name__)
+
+
+def _completeness(
+ row_count: int, source_total: int | None, limit: int
+) -> tuple[int | None, bool]:
+ """Resolve (total_rows, truncated) for a query result. A source total is only
+ authoritative when it exceeds the returned count; a total equal to a capped
+ fetch is just the cap (the upstream rowcount is the length of the already
+ limited dataframe), so treat it as unknown (None). Truncated means fewer rows
+ were returned than exist.
+ """
+ if source_total is not None and source_total > row_count:
+ return source_total, True
+ if row_count >= limit:
+ return None, True
+ return (source_total if source_total is not None else row_count), False
+
+
+def _order_by_layout(slices: list["Slice"], position_json: str | None) -> list["Slice"]:
+ """Order slices by their layout reading order so a bounded selection covers
+ the most prominent charts first; charts absent from the layout keep their
+ original order."""
+ _, positions = _extract_layout_from_position(position_json)
+ order = {
+ position.chart_id: index
+ for index, position in enumerate(positions)
+ if position.chart_id is not None
+ }
+ return sorted(slices, key=lambda slc: order.get(slc.id, len(order)))
+
+
+def _summarize_chart_data(
+ result: ChartData,
+ extra: dict[str, Any] | None,
+ request: "GetDashboardDataRequest",
+) -> DashboardChartData:
+ """Compact per-chart summary. Uses the first non-empty query layer for the
+ headline so an empty leading layer is not reported as empty, preserves each
+ layer's authoritative total, and includes a per-layer breakdown for
+ multi-query charts."""
+ layers = result.query_results or []
+ primary = next((q for q in layers if q.data), layers[0] if layers else None)
+ limit = request.fetch_row_limit
+ if primary is not None:
+ columns = primary.columns
+ sample_data = primary.data[: request.sample_rows]
+ row_count = primary.row_count
+ total_rows, _ = _completeness(row_count, primary.total_rows, limit)
+ # A chart is incomplete if any layer returned fewer than its total.
+ truncated = any(
+ _completeness(lyr.row_count, lyr.total_rows, limit)[1] for lyr in layers
+ )
+ else:
+ columns = [column.name for column in result.columns]
+ sample_data = result.data[: request.sample_rows]
+ row_count = result.row_count
+ total_rows, truncated = _completeness(row_count, result.total_rows, limit)
+ queries: list[DashboardChartQueryData] | None = None
+ if len(layers) > 1:
+ queries = []
+ for layer in layers:
+ l_total, l_truncated = _completeness(
+ layer.row_count, layer.total_rows, limit
+ )
+ queries.append(
+ DashboardChartQueryData(
+ query_index=layer.query_index,
+ columns=layer.columns,
+ sample_data=layer.data[: request.sample_rows],
+ row_count=layer.row_count,
+ total_rows=l_total,
+ truncated=l_truncated,
+ )
+ )
+ return DashboardChartData(
+ chart_id=result.chart_id,
+ chart_name=result.chart_name,
+ chart_type=result.chart_type,
+ columns=columns,
+ sample_data=sample_data,
+ row_count=row_count,
+ total_rows=total_rows,
+ truncated=truncated,
+ queries=queries,
+ filtered=bool(extra),
+ )
+
+
+@tool(
+ tags=["data"],
+ class_permission_name="Dashboard",
+ annotations=ToolAnnotations(
+ title="Get dashboard data",
+ readOnlyHint=True,
+ destructiveHint=False,
+ openWorldHint=False,
+ ),
+)
+async def get_dashboard_data(
+ request: GetDashboardDataRequest, ctx: Context
+) -> DashboardData | DashboardError:
+ """Get bounded, filter-aware data across a dashboard's charts.
+
+ For each chart (up to max_charts, selected in dashboard layout order),
+ returns a compact view of the underlying data: column names, a few sample
+ rows, and row counts. Active dashboard filters are applied per chart via
+ applied_filters (keyed by chart id, each value extra_form_data). Use this to
+ answer analytical questions about a whole dashboard in one call instead of
+ calling get_chart_data for every chart.
+
+ Example:
+ ```json
+ {
+ "identifier": 6,
+ "applied_filters": {
+ "56": {"filters": [{"col": "country", "op": "IN", "val": ["US"]}]}
+ }
+ }
+ ```
+ """
+ await ctx.info(
+ "Retrieving dashboard data: identifier=%s, max_charts=%s"
+ % (request.identifier, request.max_charts)
+ )
+
+ try:
+ from superset.daos.dashboard import DashboardDAO
+
+ with event_logger.log_context(action="mcp.get_dashboard_data.lookup"):
+ dashboard = DashboardDAO.get_by_id_or_slug(str(request.identifier))
+ except Exception as exc: # noqa: BLE001
+ await ctx.warning(
+ "Dashboard not found or inaccessible: identifier=%s, error=%s"
+ % (request.identifier, str(exc))
+ )
+ return DashboardError(
+ error=f"Dashboard not found or inaccessible: {request.identifier}",
+ error_type="DashboardNotFound",
+ timestamp=datetime.now(timezone.utc),
+ )
+
+ # Composing the core skips get_chart_data's Chart read gate, so enforce it
+ # here. Guests stay supported: check_tool_permission honors the allow-list.
+ from superset.mcp_service.auth import check_tool_permission
+
+ if not check_tool_permission(get_chart_data):
+ await ctx.warning(
+ "Chart read permission required for dashboard data: identifier=%s"
+ % (request.identifier,)
+ )
+ return DashboardError(
+ error="Chart read permission is required to read dashboard chart data",
+ error_type="ChartAccessDenied",
+ timestamp=datetime.now(timezone.utc),
+ )
+
+ slices = list(dashboard.slices or [])
+ applied_filters = request.applied_filters or {}
+ selected = _order_by_layout(slices, dashboard.position_json)[: request.max_charts]
+
+ charts: list[DashboardChartData] = []
+ deadline = monotonic() + request.time_budget_seconds
+ for slc in selected:
+ # `charts` guards the budget so at least one chart is always attempted.
+ if charts and monotonic() >= deadline:
+ await ctx.warning(
+ "Dashboard data time budget reached after %s charts" % len(charts)
+ )
+ break
+ extra = applied_filters.get(str(slc.id))
+ try:
+ chart_request = GetChartDataRequest(
+ identifier=slc.id,
+ extra_form_data=extra or None,
+ limit=request.fetch_row_limit,
+ )
+ # Reuse get_chart_data's core so guest auth + filter handling match.
+ result = await execute_chart_data(chart_request, ctx)
+ except Exception as exc: # noqa: BLE001
+ await ctx.warning(
+ "Chart data failed: chart_id=%s, error=%s" % (slc.id, str(exc))
+ )
+ charts.append(
+ DashboardChartData(
+ chart_id=slc.id,
+ chart_name=slc.slice_name or "",
+ chart_type=slc.viz_type or "unknown",
+ filtered=bool(extra),
+ error=str(exc),
+ )
+ )
+ continue
+
+ if isinstance(result, ChartData):
+ charts.append(_summarize_chart_data(result, extra, request))
+ elif isinstance(result, ChartError):
+ charts.append(
+ DashboardChartData(
+ chart_id=slc.id,
+ chart_name=slc.slice_name or "",
+ chart_type=slc.viz_type or "unknown",
+ filtered=bool(extra),
+ error=result.message,
+ )
+ )
+
+ response = DashboardData(
+ dashboard_id=dashboard.id,
+ dashboard_name=dashboard.dashboard_title or "",
+ chart_count=len(slices),
+ charts_returned=len(charts),
+ charts_truncated=len(charts) < len(slices),
+ charts=charts,
+ )
+ await ctx.info(
+ "Dashboard data retrieved: id=%s, charts_returned=%s, truncated=%s"
+ % (
+ response.dashboard_id,
+ response.charts_returned,
+ response.charts_truncated,
+ )
+ )
+ return response
diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py
index 690f31e26e6..124d87acc96 100644
--- a/superset/mcp_service/mcp_config.py
+++ b/superset/mcp_service/mcp_config.py
@@ -204,6 +204,7 @@ MCP_EMBEDDED_GUEST_AUTH_ENABLED: bool = False
MCP_GUEST_ALLOWED_TOOLS: set[str] = {
"get_dashboard_info",
"get_dashboard_layout",
+ "get_dashboard_data",
"list_dashboards",
"list_charts",
"get_chart_info",
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_data.py b/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_data.py
new file mode 100644
index 00000000000..237a2b47281
--- /dev/null
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_data.py
@@ -0,0 +1,480 @@
+# 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 get_dashboard_data MCP tool."""
+
+from typing import Any
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+from fastmcp import Client
+from pydantic import ValidationError
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.schemas import (
+ ChartData,
+ ChartError,
+ ChartQueryResult,
+ DataColumn,
+ PerformanceMetadata,
+)
+from superset.utils import json
+
+TOOL = "get_dashboard_data"
+EXEC = "superset.mcp_service.dashboard.tool.get_dashboard_data.execute_chart_data"
+DAO = "superset.daos.dashboard.DashboardDAO.get_by_id_or_slug"
+PERM = "superset.mcp_service.auth.check_tool_permission"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+def _slice(chart_id: int, name: str, viz: str = "table") -> Mock:
+ slc = Mock()
+ slc.id = chart_id
+ slc.slice_name = name
+ slc.viz_type = viz
+ return slc
+
+
+def _dashboard(
+ dash_id: int,
+ title: str,
+ slices: list[Mock],
+ position_json: str | None = None,
+) -> Mock:
+ dashboard = Mock()
+ dashboard.id = dash_id
+ dashboard.dashboard_title = title
+ dashboard.slices = slices
+ dashboard.position_json = position_json
+ return dashboard
+
+
+_UNSET = object()
+
+
+def _chart_data(
+ chart_id: int,
+ name: str,
+ viz: str = "table",
+ rows: list[dict[str, Any]] | None = None,
+ query_results: list[ChartQueryResult] | None = None,
+ total_rows: Any = _UNSET,
+) -> ChartData:
+ rows = rows if rows is not None else [{"country": "US", "cnt": 10}]
+ resolved_total = len(rows) if total_rows is _UNSET else total_rows
+ return ChartData(
+ query_results=query_results,
+ chart_id=chart_id,
+ chart_name=name,
+ chart_type=viz,
+ columns=[
+ DataColumn(
+ name="country",
+ display_name="Country",
+ data_type="VARCHAR",
+ sample_values=["US"],
+ null_count=0,
+ unique_count=1,
+ )
+ ],
+ data=rows,
+ row_count=len(rows),
+ total_rows=resolved_total,
+ data_freshness=None,
+ summary=f"{name} summary",
+ insights=[f"{name} insight"],
+ data_quality={},
+ recommended_visualizations=[],
+ performance=PerformanceMetadata(query_duration_ms=1, cache_status="miss"),
+ )
+
+
+async def _call(client: Client, request: dict[str, Any]) -> dict[str, Any]:
+ result = await client.call_tool(TOOL, {"request": request})
+ return json.loads(result.content[0].text)
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_returns_compact_summary_per_chart(mock_dao, mock_exec, mcp_server):
+ mock_dao.return_value = _dashboard(
+ 6, "FCC Survey", [_slice(56, "Gender"), _slice(99, "Age")]
+ )
+ mock_exec.side_effect = [_chart_data(56, "Gender"), _chart_data(99, "Age")]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ assert data["dashboard_id"] == 6
+ assert data["chart_count"] == 2
+ assert data["charts_returned"] == 2
+ assert data["charts_truncated"] is False
+ assert [c["chart_name"] for c in data["charts"]] == ["Gender", "Age"]
+
+ first = data["charts"][0]
+ assert first["columns"] == ["country"]
+ assert first["sample_data"] == [{"country": "US", "cnt": 10}]
+ assert first["row_count"] == 1
+ # Shallow summary/insights fields were dropped to keep the payload lean.
+ assert "summary" not in first
+ assert "insights" not in first
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_truncates_to_max_charts(mock_dao, mock_exec, mcp_server):
+ mock_dao.return_value = _dashboard(
+ 6, "D", [_slice(1, "a"), _slice(2, "b"), _slice(3, "c")]
+ )
+ mock_exec.side_effect = [_chart_data(1, "a"), _chart_data(2, "b")]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6, "max_charts": 2})
+
+ assert data["chart_count"] == 3
+ assert data["charts_returned"] == 2
+ assert data["charts_truncated"] is True
+ assert mock_exec.call_count == 2
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_applies_filters_only_to_scoped_chart(mock_dao, mock_exec, mcp_server):
+ mock_dao.return_value = _dashboard(
+ 6, "D", [_slice(56, "Gender"), _slice(99, "Age")]
+ )
+ mock_exec.side_effect = [_chart_data(56, "Gender"), _chart_data(99, "Age")]
+ gender_filter = {"filters": [{"col": "gender", "op": "IN", "val": ["Female"]}]}
+
+ async with Client(mcp_server) as client:
+ data = await _call(
+ client, {"identifier": 6, "applied_filters": {"56": gender_filter}}
+ )
+
+ by_id = {c["chart_id"]: c for c in data["charts"]}
+ assert by_id[56]["filtered"] is True
+ assert by_id[99]["filtered"] is False
+
+ # Only the scoped chart's query received the extra_form_data.
+ passed = {
+ call.args[0].identifier: call.args[0].extra_form_data
+ for call in mock_exec.call_args_list
+ }
+ assert passed[56] == gender_filter
+ assert passed[99] is None
+ # fetch_row_limit is the per-chart bound; pin that it reaches the query.
+ assert {call.args[0].limit for call in mock_exec.call_args_list} == {100}
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_chart_error_is_recorded_not_fatal(mock_dao, mock_exec, mcp_server):
+ mock_dao.return_value = _dashboard(
+ 6, "D", [_slice(56, "Gender"), _slice(99, "Age")]
+ )
+ mock_exec.side_effect = [
+ ChartError(error_type="DatasetNotAccessible", message="no access"),
+ _chart_data(99, "Age"),
+ ]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ by_id = {c["chart_id"]: c for c in data["charts"]}
+ assert by_id[56]["error"] == "no access"
+ assert by_id[56]["sample_data"] == []
+ assert by_id[99]["error"] is None
+ assert by_id[99]["sample_data"] == [{"country": "US", "cnt": 10}]
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_malformed_filter_is_recorded_not_fatal(mock_dao, mock_exec, mcp_server):
+ # A non-dict applied_filters value makes GetChartDataRequest raise inside the
+ # per-chart loop; the except branch must record it as an error and keep going
+ # rather than aborting the whole dashboard.
+ mock_dao.return_value = _dashboard(
+ 6, "D", [_slice(56, "Gender"), _slice(99, "Age")]
+ )
+ mock_exec.return_value = _chart_data(99, "Age")
+
+ async with Client(mcp_server) as client:
+ data = await _call(
+ client, {"identifier": 6, "applied_filters": {"56": "not-a-dict"}}
+ )
+
+ by_id = {c["chart_id"]: c for c in data["charts"]}
+ assert by_id[56]["error"] is not None
+ assert by_id[56]["filtered"] is True
+ # The malformed chart never reaches execute_chart_data; the other still runs.
+ assert {call.args[0].identifier for call in mock_exec.call_args_list} == {99}
+ assert by_id[99]["error"] is None
+
+
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_dashboard_not_found_returns_error(mock_dao, mcp_server):
+ mock_dao.side_effect = Exception("nope")
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 999})
+
+ assert data["error_type"] == "DashboardNotFound"
+ assert "999" in data["error"]
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_selects_charts_in_layout_reading_order(mock_dao, mock_exec, mcp_server):
+ # Layout places chart 99 before chart 56, opposite the slice order.
+ position_json = json.dumps(
+ {
+ "ROOT_ID": {"id": "ROOT_ID", "type": "ROOT", "children": ["GRID_ID"]},
+ "GRID_ID": {
+ "id": "GRID_ID",
+ "type": "GRID",
+ "children": ["CHART-b", "CHART-a"],
+ },
+ "CHART-a": {
+ "id": "CHART-a",
+ "type": "CHART",
+ "meta": {"chartId": 56},
+ "children": [],
+ },
+ "CHART-b": {
+ "id": "CHART-b",
+ "type": "CHART",
+ "meta": {"chartId": 99},
+ "children": [],
+ },
+ }
+ )
+ mock_dao.return_value = _dashboard(
+ 6, "D", [_slice(56, "Gender"), _slice(99, "Age")], position_json=position_json
+ )
+ mock_exec.side_effect = [_chart_data(99, "Age")]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6, "max_charts": 1})
+
+ # With max_charts=1, the layout-first chart (99) is the one selected.
+ assert data["charts_returned"] == 1
+ assert data["charts"][0]["chart_id"] == 99
+ # Assert on the slice actually queried, not the mocked result's id, so a
+ # broken _order_by_layout (unchanged or reversed) cannot pass.
+ assert mock_exec.call_args.args[0].identifier == 99
+
+
+@patch("superset.mcp_service.dashboard.tool.get_dashboard_data.monotonic")
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_stops_at_time_budget(mock_dao, mock_exec, mock_monotonic, mcp_server):
+ # First monotonic() sets the deadline; the next read lands past it, so the
+ # loop stops after the first chart. Patch the tool-local name so only the
+ # tool's calls are mocked, never the process-wide time.monotonic.
+ mock_monotonic.side_effect = [0.0, 100.0]
+ mock_dao.return_value = _dashboard(
+ 6, "D", [_slice(1, "a"), _slice(2, "b"), _slice(3, "c")]
+ )
+ mock_exec.side_effect = [
+ _chart_data(1, "a"),
+ _chart_data(2, "b"),
+ _chart_data(3, "c"),
+ ]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ # At least one chart is always attempted; the budget cuts off the rest.
+ assert data["charts_returned"] == 1
+ assert data["charts_truncated"] is True
+ assert mock_exec.call_count == 1
+
+
+def _deny_chart_only(func, **kwargs):
+ # Let the Dashboard-gated tool through the framework gate; deny only the inner
+ # Chart read check that get_dashboard_data performs before running queries.
+ return getattr(func, "_class_permission_name", None) != "Chart"
+
+
+@patch(PERM, side_effect=_deny_chart_only)
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_denies_without_chart_permission(
+ mock_dao, mock_exec, mock_perm, mcp_server
+):
+ # Composing get_chart_data's core must still enforce its Chart read gate, so a
+ # principal with Dashboard read but not Chart read gets no chart data.
+ mock_dao.return_value = _dashboard(6, "D", [_slice(56, "Gender")])
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ assert data["error_type"] == "ChartAccessDenied"
+ mock_exec.assert_not_called()
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_preserves_multi_query_layers(mock_dao, mock_exec, mcp_server):
+ # A multi-query chart (e.g. Mixed Timeseries) whose first layer is empty and
+ # second has data must not be reported as empty, and both layers survive.
+ mock_dao.return_value = _dashboard(6, "D", [_slice(56, "Mixed")])
+ mock_exec.side_effect = [
+ _chart_data(
+ 56,
+ "Mixed",
+ query_results=[
+ ChartQueryResult(query_index=0, columns=[], data=[], row_count=0),
+ ChartQueryResult(
+ query_index=1, columns=["y"], data=[{"y": 5}], row_count=1
+ ),
+ ],
+ )
+ ]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ chart = data["charts"][0]
+ # Headline sample comes from the non-empty layer, not the empty first one.
+ assert chart["columns"] == ["y"]
+ assert chart["sample_data"] == [{"y": 5}]
+ # Both layers are preserved under queries.
+ assert [q["query_index"] for q in chart["queries"]] == [0, 1]
+ assert chart["queries"][1]["sample_data"] == [{"y": 5}]
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_flags_truncated_and_hides_capped_total(mock_dao, mock_exec, mcp_server):
+ # Production shape: the upstream rowcount equals the capped fetch (100 rows,
+ # total_rows=100, limit=100), which is the cap, not a real total. It must be
+ # reported truncated with an unknown total, not as total_rows=100.
+ mock_dao.return_value = _dashboard(6, "D", [_slice(56, "Big"), _slice(99, "Small")])
+ mock_exec.side_effect = [
+ _chart_data(56, "Big", rows=[{"n": i} for i in range(100)], total_rows=100),
+ _chart_data(99, "Small", rows=[{"n": 1}]),
+ ]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ by_id = {c["chart_id"]: c for c in data["charts"]}
+ assert by_id[56]["row_count"] == 100
+ assert by_id[56]["truncated"] is True
+ assert by_id[56]["total_rows"] is None
+ # A complete fetch reports the real total and is not truncated.
+ assert by_id[99]["truncated"] is False
+ assert by_id[99]["total_rows"] == 1
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_preserves_authoritative_total_below_cap(mock_dao, mock_exec, mcp_server):
+ # A known source total larger than the returned rows is preserved (not
+ # overwritten by row_count) and flagged truncated, even below the fetch cap.
+ mock_dao.return_value = _dashboard(6, "D", [_slice(56, "Partial")])
+ mock_exec.side_effect = [
+ _chart_data(56, "Partial", rows=[{"n": i} for i in range(5)], total_rows=12),
+ ]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ chart = data["charts"][0]
+ assert chart["row_count"] == 5
+ assert chart["total_rows"] == 12
+ assert chart["truncated"] is True
+
+
+@patch(EXEC, new_callable=AsyncMock)
+@patch(DAO)
+@pytest.mark.asyncio
+async def test_truncation_surfaces_from_any_layer(mock_dao, mock_exec, mcp_server):
+ # A capped non-headline layer must still flag the whole chart truncated, so a
+ # consumer reading only the top-level fields is not misled.
+ mock_dao.return_value = _dashboard(6, "D", [_slice(56, "Mixed")])
+ mock_exec.side_effect = [
+ _chart_data(
+ 56,
+ "Mixed",
+ query_results=[
+ ChartQueryResult(
+ query_index=0, columns=["a"], data=[{"a": 1}], row_count=1
+ ),
+ ChartQueryResult(
+ query_index=1,
+ columns=["b"],
+ data=[{"b": i} for i in range(100)],
+ row_count=100,
+ ),
+ ],
+ )
+ ]
+
+ async with Client(mcp_server) as client:
+ data = await _call(client, {"identifier": 6})
+
+ chart = data["charts"][0]
+ # Headline is the first non-empty layer (0), but layer 1 is capped.
+ assert chart["row_count"] == 1
+ assert chart["truncated"] is True
+ # Headline layer's own total is preserved; layer 1's cap drives truncated.
+ assert chart["total_rows"] == 1
+ assert chart["queries"][0]["truncated"] is False
+ assert chart["queries"][1]["truncated"] is True
+
+
+def test_rejects_bool_identifier():
+ # bool is an int subclass; identifier=True must not coerce to dashboard 1.
+ from superset.mcp_service.dashboard.schemas import GetDashboardDataRequest
+
+ with pytest.raises(ValidationError):
+ GetDashboardDataRequest(identifier=True)
+ assert GetDashboardDataRequest(identifier=6).identifier == 6
+
+
+@pytest.mark.parametrize(
+ "row_count,source_total,limit,expected",
+ [
+ (100, 100, 100, (None, True)), # equal-at-cap: the cap, not a real total
+ (5, 12, 100, (12, True)), # known total above returned -> preserved
+ (5, 5, 100, (5, False)), # complete: source equals count
+ (100, None, 100, (None, True)), # capped, unknown total
+ (1, None, 100, (1, False)), # small, no source total
+ ],
+)
+def test_completeness(row_count, source_total, limit, expected):
+ from superset.mcp_service.dashboard.tool.get_dashboard_data import _completeness
+
+ assert _completeness(row_count, source_total, limit) == expected