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