This is an automated email from the ASF dual-hosted git repository. msyavuz pushed a commit to branch msyavuz/feat/mcp-get-dashboard-layout in repository https://gitbox.apache.org/repos/asf/superset.git
commit 97c42a1ddd061bce8660a0ef5e529fae3c6a5d09 Author: Mehmet Salih Yavuz <[email protected]> AuthorDate: Wed May 20 18:00:01 2026 +0300 feat(mcp): add get_dashboard_layout companion tool --- superset/mcp_service/app.py | 2 + superset/mcp_service/dashboard/schemas.py | 254 +++++++++++++++++- superset/mcp_service/dashboard/tool/__init__.py | 2 + .../dashboard/tool/get_dashboard_layout.py | 122 +++++++++ .../dashboard/tool/test_get_dashboard_layout.py | 285 +++++++++++++++++++++ 5 files changed, 664 insertions(+), 1 deletion(-) diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 0a68d168a07..857e926d460 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -116,6 +116,7 @@ Available tools: 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) - generate_dashboard: Create a dashboard from chart IDs - add_chart_to_existing_dashboard: Add a chart to an existing dashboard @@ -605,6 +606,7 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402 add_chart_to_existing_dashboard, generate_dashboard, get_dashboard_info, + get_dashboard_layout, list_dashboards, ) from superset.mcp_service.database.tool import ( # noqa: F401, E402 diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 68a680863e5..ef9b5269b73 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -307,6 +307,17 @@ class GetDashboardInfoRequest(MetadataCacheControl): ) +class GetDashboardLayoutRequest(BaseModel): + """Request schema for get_dashboard_layout.""" + + identifier: Annotated[ + int | str, + Field( + description="Dashboard identifier - can be numeric ID, UUID string, or slug" + ), + ] + + logger = logging.getLogger(__name__) @@ -618,6 +629,71 @@ class GenerateDashboardResponse(BaseModel): ) +class ChartPosition(BaseModel): + """Position and identity of a chart within a dashboard layout.""" + + chart_id: int | None = Field(None, description="Chart (slice) ID") + slice_name: str | None = Field( + None, + description=( + "Display name as configured in the layout (sliceNameOverride or sliceName)" + ), + ) + tab_id: str | None = Field( + None, + description=( + "ID of the tab that contains this chart, or None for charts not nested " + "under any TAB component." + ), + ) + tab_path: List[str] = Field( + default_factory=list, + description=( + "Names of ancestor tabs (outermost first) so the agent can describe " + "where the chart lives in nested tab layouts." + ), + ) + width: int | None = Field(None, description="Grid column width") + height: int | None = Field(None, description="Grid row height") + + +class DashboardTab(BaseModel): + """A tab in a dashboard layout.""" + + id: str = Field(..., description="Tab component ID from position_json") + name: str | None = Field(None, description="Tab display name") + parent_tab_id: str | None = Field( + None, + description=("ID of the enclosing tab when tabs are nested, otherwise None."), + ) + chart_ids: List[int] = Field( + default_factory=list, + description="IDs of charts contained directly or indirectly under this tab", + ) + + +class DashboardLayout(BaseModel): + """Parsed layout data for a dashboard, derived from position_json.""" + + 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") + tabs: List[DashboardTab] = Field( + default_factory=list, + description=( + "Tabs declared in the dashboard layout (empty for untabbed dashboards)" + ), + ) + charts: List[ChartPosition] = Field( + default_factory=list, + description="Charts placed in the dashboard layout with their tab context", + ) + has_layout: bool = Field( + default=False, + description="False when position_json is missing or empty", + ) + + def _parse_json_metadata(json_metadata_str: str | None) -> Dict[str, Any] | None: """Parse json_metadata string into a dict, returning None on any failure. @@ -689,6 +765,126 @@ def _extract_cross_filters_enabled(json_metadata_str: str | None) -> bool | None return None +def _parse_position_json( + position_json_str: str | None, +) -> Dict[str, Any] | None: + """Parse position_json into a dict, returning None on any failure.""" + if not position_json_str: + return None + try: + data = json_loads(position_json_str) + except (ValueError, TypeError): + return None + if not isinstance(data, dict): + return None + return data + + +def _record_tab( + node_id: str, + meta: Dict[str, Any], + tab_ancestry: tuple[str, ...], + tabs_by_id: Dict[str, DashboardTab], +) -> None: + """Register a TAB node into tabs_by_id keyed by component id.""" + raw_text = meta.get("text") + tab_name = raw_text if isinstance(raw_text, str) else None + tabs_by_id[node_id] = DashboardTab( + id=node_id, + name=tab_name, + parent_tab_id=tab_ancestry[-1] if tab_ancestry else None, + ) + + +def _record_chart( + meta: Dict[str, Any], + tab_ancestry: tuple[str, ...], + tabs_by_id: Dict[str, DashboardTab], + charts: List[ChartPosition], +) -> None: + """Record a CHART node's position and update enclosing tabs.""" + raw_chart_id = meta.get("chartId") + chart_id = raw_chart_id if isinstance(raw_chart_id, int) else None + display_name = meta.get("sliceNameOverride") or meta.get("sliceName") + raw_width = meta.get("width") + raw_height = meta.get("height") + charts.append( + ChartPosition( + chart_id=chart_id, + slice_name=display_name if isinstance(display_name, str) else None, + tab_id=tab_ancestry[-1] if tab_ancestry else None, + tab_path=[tabs_by_id[t].name or t for t in tab_ancestry if t in tabs_by_id], + width=raw_width if isinstance(raw_width, int) else None, + height=raw_height if isinstance(raw_height, int) else None, + ) + ) + if chart_id is None: + return + for ancestor_id in tab_ancestry: + tab = tabs_by_id.get(ancestor_id) + if tab is not None and chart_id not in tab.chart_ids: + tab.chart_ids.append(chart_id) + + +def _extract_layout_from_position( + position_json_str: str | None, +) -> tuple[List[DashboardTab], List[ChartPosition]]: + """Walk position_json and return (tabs, chart_positions). + + Traverses the component tree iteratively starting from ROOT_ID. Tab + ancestry is tracked so chart placement and nested tab references stay + accurate. Malformed or missing nodes are skipped silently — partial + data is more useful than an exception here, since agents call this + tool defensively after seeing the omitted_fields hint. + """ + position = _parse_position_json(position_json_str) + if not position or "ROOT_ID" not in position: + return [], [] + + tabs_by_id: Dict[str, DashboardTab] = {} + charts: List[ChartPosition] = [] + + stack: List[tuple[str, tuple[str, ...]]] = [("ROOT_ID", ())] + visited: set[str] = set() + + while stack: + node_id, tab_ancestry = stack.pop() + if node_id in visited: + continue + visited.add(node_id) + + node = position.get(node_id) + if not isinstance(node, dict): + continue + + node_type = node.get("type") + raw_meta = node.get("meta") + meta: Dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {} + next_ancestry = tab_ancestry + + if node_type == "TAB": + _record_tab(node_id, meta, tab_ancestry, tabs_by_id) + next_ancestry = tab_ancestry + (node_id,) + elif node_type == "CHART": + _record_chart(meta, tab_ancestry, tabs_by_id, charts) + + children = node.get("children") + if isinstance(children, list): + for child_id in reversed(children): + if isinstance(child_id, str): + stack.append((child_id, next_ancestry)) + + tab_order = [ + node_id + for node_id in position + if isinstance(position.get(node_id), dict) + and position[node_id].get("type") == "TAB" + and node_id in tabs_by_id + ] + tabs = [tabs_by_id[node_id] for node_id in tab_order] + return tabs, charts + + def _build_omitted_fields( json_metadata_str: str | None, position_json_str: str | None ) -> Dict[str, str]: @@ -706,7 +902,8 @@ def _build_omitted_fields( raw_value=position_json_str, reason=( "Internal layout tree with component positions/hierarchy. " - "Not useful for analysis or LLM context." + "Call get_dashboard_layout(identifier) to retrieve parsed tabs " + "and chart positions on demand." ), ) .add_extracted_field( @@ -974,3 +1171,58 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: else [], ) ) + + +def _sanitize_dashboard_layout_for_llm_context( + layout: DashboardLayout, +) -> DashboardLayout: + """Wrap layout text fields before LLM exposure.""" + payload = layout.model_dump(mode="python") + payload["dashboard_title"] = sanitize_for_llm_context( + payload.get("dashboard_title"), + field_path=("dashboard_title",), + ) + payload["tabs"] = [ + { + **tab, + "name": sanitize_for_llm_context( + tab.get("name"), + field_path=("tabs", str(index), "name"), + ), + } + for index, tab in enumerate(payload.get("tabs", [])) + ] + payload["charts"] = [ + { + **chart, + "slice_name": sanitize_for_llm_context( + chart.get("slice_name"), + field_path=("charts", str(index), "slice_name"), + ), + "tab_path": [ + sanitize_for_llm_context( + name, + field_path=("charts", str(index), "tab_path", str(part_index)), + ) + for part_index, name in enumerate(chart.get("tab_path", []) or []) + ], + } + for index, chart in enumerate(payload.get("charts", [])) + ] + return DashboardLayout.model_validate(payload) + + +def dashboard_layout_serializer(dashboard: "Dashboard") -> DashboardLayout: + """Serialize a Dashboard model to a parsed DashboardLayout.""" + position_json_str = getattr(dashboard, "position_json", None) + tabs, charts = _extract_layout_from_position(position_json_str) + return _sanitize_dashboard_layout_for_llm_context( + DashboardLayout( + id=dashboard.id, + dashboard_title=dashboard.dashboard_title or "Untitled", + uuid=str(dashboard.uuid) if dashboard.uuid else None, + tabs=tabs, + charts=charts, + has_layout=bool(position_json_str), + ) + ) diff --git a/superset/mcp_service/dashboard/tool/__init__.py b/superset/mcp_service/dashboard/tool/__init__.py index 7c00bf8e384..389acfb192a 100644 --- a/superset/mcp_service/dashboard/tool/__init__.py +++ b/superset/mcp_service/dashboard/tool/__init__.py @@ -18,11 +18,13 @@ from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard from .generate_dashboard import generate_dashboard from .get_dashboard_info import get_dashboard_info +from .get_dashboard_layout import get_dashboard_layout from .list_dashboards import list_dashboards __all__ = [ "list_dashboards", "get_dashboard_info", + "get_dashboard_layout", "generate_dashboard", "add_chart_to_existing_dashboard", ] diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_layout.py b/superset/mcp_service/dashboard/tool/get_dashboard_layout.py new file mode 100644 index 00000000000..6ce20185b0f --- /dev/null +++ b/superset/mcp_service/dashboard/tool/get_dashboard_layout.py @@ -0,0 +1,122 @@ +# 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 layout FastMCP tool + +Companion to get_dashboard_info: returns the parsed dashboard layout +(tabs and chart positions) extracted from position_json. Use this +when get_dashboard_info's omitted_fields hint indicates position_json +was stripped and structured layout data is needed for analysis. +""" + +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_layout_serializer, + DashboardError, + DashboardLayout, + GetDashboardLayoutRequest, +) +from superset.mcp_service.mcp_core import ModelGetInfoCore + +logger = logging.getLogger(__name__) + + +@tool( + tags=["discovery"], + class_permission_name="Dashboard", + annotations=ToolAnnotations( + title="Get dashboard layout", + readOnlyHint=True, + destructiveHint=False, + ), +) +async def get_dashboard_layout( + request: GetDashboardLayoutRequest, ctx: Context +) -> DashboardLayout | DashboardError: + """ + Get parsed dashboard layout by ID, UUID, or slug. + + Returns the tabs and chart positions extracted from the dashboard's + position_json. get_dashboard_info omits position_json to keep responses + small; call this tool when you need the structured layout (e.g. to + explain which charts live under which tab, or to locate a chart by + its parent tab). + + Example usage: + ```json + { + "identifier": 123 + } + ``` + """ + await ctx.info("Retrieving dashboard layout: identifier=%s" % (request.identifier,)) + + try: + from superset.daos.dashboard import DashboardDAO + from superset.models.dashboard import Dashboard + + eager_options = [subqueryload(Dashboard.slices)] + + with event_logger.log_context(action="mcp.get_dashboard_layout.lookup"): + core = ModelGetInfoCore( + dao_class=DashboardDAO, + output_schema=DashboardLayout, + error_schema=DashboardError, + serializer=dashboard_layout_serializer, + supports_slug=True, + logger=logger, + query_options=eager_options, + ) + result = core.run_tool(request.identifier) + + if isinstance(result, DashboardLayout): + await ctx.info( + "Dashboard layout retrieved: id=%s, tab_count=%s, chart_count=%s, " + "has_layout=%s" + % ( + result.id, + len(result.tabs), + len(result.charts), + result.has_layout, + ) + ) + else: + await ctx.warning( + "Dashboard layout retrieval failed: error_type=%s, error=%s" + % (result.error_type, result.error) + ) + + return result + + except Exception as e: + await ctx.error( + "Dashboard layout retrieval failed: identifier=%s, error=%s, " + "error_type=%s" % (request.identifier, str(e), type(e).__name__) + ) + return DashboardError( + error=f"Failed to get dashboard layout: {str(e)}", + error_type="InternalError", + timestamp=datetime.now(timezone.utc), + ) diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_layout.py b/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_layout.py new file mode 100644 index 00000000000..180a02bba2c --- /dev/null +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_get_dashboard_layout.py @@ -0,0 +1,285 @@ +# 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_layout tool.""" + +from unittest.mock import Mock, patch + +import pytest +from fastmcp import Client + +from superset.mcp_service.app import mcp +from superset.mcp_service.dashboard.schemas import ( + _extract_layout_from_position, +) +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_dashboard_mock( + *, + dashboard_id: int = 1, + title: str = "Test Dashboard", + uuid: str | None = "dashboard-uuid-1", + position_json: str | None = None, +) -> Mock: + dashboard = Mock() + dashboard.id = dashboard_id + dashboard.dashboard_title = title + dashboard.uuid = uuid + dashboard.position_json = position_json + dashboard.slices = [] + return dashboard + + [email protected] +def mcp_server(): + return mcp + + [email protected](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 + + +def _simple_layout() -> str: + return json.dumps( + { + "DASHBOARD_VERSION_KEY": "v2", + "ROOT_ID": { + "type": "ROOT", + "id": "ROOT_ID", + "children": ["GRID_ID"], + }, + "GRID_ID": { + "type": "GRID", + "id": "GRID_ID", + "parents": ["ROOT_ID"], + "children": ["ROW-1"], + }, + "ROW-1": { + "type": "ROW", + "id": "ROW-1", + "parents": ["ROOT_ID", "GRID_ID"], + "children": ["CHART-a"], + "meta": {"background": "BACKGROUND_TRANSPARENT"}, + }, + "CHART-a": { + "type": "CHART", + "id": "CHART-a", + "parents": ["ROOT_ID", "GRID_ID", "ROW-1"], + "children": [], + "meta": { + "chartId": 42, + "sliceName": "Revenue Chart", + "width": 4, + "height": 50, + }, + }, + } + ) + + +def _tabbed_layout() -> str: + return json.dumps( + { + "DASHBOARD_VERSION_KEY": "v2", + "ROOT_ID": { + "type": "ROOT", + "id": "ROOT_ID", + "children": ["TABS-1"], + }, + "TABS-1": { + "type": "TABS", + "id": "TABS-1", + "parents": ["ROOT_ID"], + "children": ["TAB-1", "TAB-2"], + "meta": {}, + }, + "TAB-1": { + "type": "TAB", + "id": "TAB-1", + "parents": ["ROOT_ID", "TABS-1"], + "children": ["ROW-1"], + "meta": {"text": "Overview"}, + }, + "ROW-1": { + "type": "ROW", + "id": "ROW-1", + "parents": ["ROOT_ID", "TABS-1", "TAB-1"], + "children": ["CHART-a"], + "meta": {}, + }, + "CHART-a": { + "type": "CHART", + "id": "CHART-a", + "parents": ["ROOT_ID", "TABS-1", "TAB-1", "ROW-1"], + "children": [], + "meta": { + "chartId": 10, + "sliceNameOverride": "Top KPIs", + "sliceName": "Top KPIs Source", + "width": 6, + "height": 40, + }, + }, + "TAB-2": { + "type": "TAB", + "id": "TAB-2", + "parents": ["ROOT_ID", "TABS-1"], + "children": ["ROW-2"], + "meta": {"text": "Details"}, + }, + "ROW-2": { + "type": "ROW", + "id": "ROW-2", + "parents": ["ROOT_ID", "TABS-1", "TAB-2"], + "children": ["CHART-b"], + "meta": {}, + }, + "CHART-b": { + "type": "CHART", + "id": "CHART-b", + "parents": ["ROOT_ID", "TABS-1", "TAB-2", "ROW-2"], + "children": [], + "meta": { + "chartId": 20, + "sliceName": "Detail Chart", + "width": 12, + "height": 60, + }, + }, + } + ) + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") [email protected] +async def test_get_dashboard_layout_basic(mock_find, mcp_server): + mock_find.return_value = _build_dashboard_mock(position_json=_simple_layout()) + + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_layout", {"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["has_layout"] is True + assert data["tabs"] == [] + assert len(data["charts"]) == 1 + chart = data["charts"][0] + assert chart["chart_id"] == 42 + assert chart["slice_name"] == _wrapped("Revenue Chart") + assert chart["tab_id"] is None + assert chart["tab_path"] == [] + assert chart["width"] == 4 + assert chart["height"] == 50 + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") [email protected] +async def test_get_dashboard_layout_tabbed(mock_find, mcp_server): + mock_find.return_value = _build_dashboard_mock( + title="Sales Overview", position_json=_tabbed_layout() + ) + + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_layout", {"request": {"identifier": 1}} + ) + data = json.loads(result.content[0].text) + + assert data["has_layout"] is True + assert [t["id"] for t in data["tabs"]] == ["TAB-1", "TAB-2"] + assert data["tabs"][0]["name"] == _wrapped("Overview") + assert data["tabs"][0]["chart_ids"] == [10] + assert data["tabs"][1]["name"] == _wrapped("Details") + assert data["tabs"][1]["chart_ids"] == [20] + + charts_by_id = {c["chart_id"]: c for c in data["charts"]} + assert charts_by_id[10]["slice_name"] == _wrapped("Top KPIs") + assert charts_by_id[10]["tab_id"] == "TAB-1" + assert charts_by_id[10]["tab_path"] == [_wrapped("Overview")] + assert charts_by_id[20]["tab_id"] == "TAB-2" + assert charts_by_id[20]["tab_path"] == [_wrapped("Details")] + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") [email protected] +async def test_get_dashboard_layout_empty(mock_find, mcp_server): + mock_find.return_value = _build_dashboard_mock(position_json=None) + + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_layout", {"request": {"identifier": 1}} + ) + data = json.loads(result.content[0].text) + + assert data["has_layout"] is False + assert data["tabs"] == [] + assert data["charts"] == [] + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") [email protected] +async def test_get_dashboard_layout_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_layout", {"request": {"identifier": 999}} + ) + data = json.loads(result.content[0].text) + + assert data["error_type"] == "not_found" + + +def test_extract_layout_handles_invalid_json(): + tabs, charts = _extract_layout_from_position("{ not json") + assert tabs == [] + assert charts == [] + + +def test_extract_layout_handles_missing_root(): + tabs, charts = _extract_layout_from_position(json.dumps({"FOO": {"type": "ROW"}})) + assert tabs == [] + assert charts == [] + + +def test_get_dashboard_info_omitted_fields_references_layout_tool(): + """The position_json omission message must point agents at get_dashboard_layout.""" + from superset.mcp_service.dashboard.schemas import _build_omitted_fields + + omitted = _build_omitted_fields( + json_metadata_str=None, position_json_str='{"ROOT_ID": {}}' + ) + assert "get_dashboard_layout" in omitted["position_json"]
