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"]

Reply via email to