This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 33292f249da feat(mcp): support tab-scoped dashboard layouts (#44797)
33292f249da is described below
commit 33292f249dad32c08921d6eac2a866be53c6c5e5
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Fri Oct 2 08:42:33 2026 +1000
feat(mcp): support tab-scoped dashboard layouts (#44797)
---
docs/admin_docs/configuration/mcp-server.mdx | 30 +
superset/mcp_service/dashboard/schemas.py | 103 ++-
.../dashboard/tool/get_dashboard_layout.py | 161 ++++-
superset/mcp_service/middleware.py | 7 +-
superset/mcp_service/utils/response_size_utils.py | 176 +++++
.../dashboard/tool/test_get_dashboard_layout.py | 744 ++++++++++++++++++++-
6 files changed, 1202 insertions(+), 19 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx
b/docs/admin_docs/configuration/mcp-server.mdx
index a2b91b10479..6a247989508 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1268,6 +1268,36 @@ filters update the `BASE_AXIS` column's `timeGrain`,
regardless of its position
in the query's `columns` list. Other columns are left untouched. Queries
without
a `BASE_AXIS` column continue to use `extras.time_grain_sqla`.
+### Exploring large dashboard layouts
+
+`get_dashboard_layout` returns the full parsed layout by default. For large
+dashboards, pass `tabs_only=true` to retrieve just the tab tree in `tab_tree`:
+`id`, `name` (the title), `parent_tab_id`, zero-based `depth`, and
+`chart_count` (distinct charts in the tab and its descendants). In this mode
+`tabs` and `charts` are empty by request, not because the dashboard is empty.
+Then pass `tab="<ID or exact title>"` to retrieve that tab and its descendants
+with chart positions. Matching is case-sensitive; IDs take precedence and
+ambiguous titles require an ID. Both options can be combined to summarize a
+subtree. Parent IDs, depths, and chart paths retain their full-dashboard
context.
+
+Charts placed outside every tab are counted in `untabbed_chart_count` in every
+response; pass `untabbed_only=true` (without `tab` or `tabs_only`) to list only
+those charts. The `scope` field echoes the applied `tabs_only`, resolved
+`tab_id`, and `untabbed_only` values, and is `null` for the full layout.
+Scoping only removes tabs and chart placements from the layout the caller can
+already retrieve. Conflicting scope options return an actionable
+`invalid_scope` error naming the options to remove; blank tab selectors return
+`tab_not_found` rather than widening the scope. On dashboards without tabs,
+`tabs_only=true` returns `tab_not_found` with guidance to use
+`untabbed_only=true` or request the full layout instead.
+
+Permalink active-tab and filter state remain unchanged; only an explicit `tab`
+scopes the response. Oversized layout errors suggest the next narrower scope:
+the tab tree and untabbed charts for a full tabbed layout, a top-level tab for
+an oversized tab tree, a nested tab for a tab that has one, and `list_charts`
+paging when a dashboard without tabs or a tab without nested tabs is still too
+large.
+
### Reading applied dashboard filters
When answering about a filtered dashboard, call `get_dashboard_info` with the
diff --git a/superset/mcp_service/dashboard/schemas.py
b/superset/mcp_service/dashboard/schemas.py
index 54cab9a0914..82073216082 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -335,29 +335,41 @@ class GetDashboardInfoRequest(MetadataCacheControl):
class GetDashboardLayoutRequest(BaseModel):
- """Request a dashboard layout by its identifier or shared permalink.
-
- Permalink requests resolve the dashboard while preserving shared active-tab
- and filter state in the response.
- """
+ """Dashboard layout, optionally scoped."""
identifier: Annotated[
int | str | None,
Field(
default=None,
description=(
- "Dashboard ID, UUID, slug, bare permalink key, or a shared URL
"
- "containing /superset/dashboard/p/<key>/. Omit when "
- "permalink_key is provided."
+ "Dashboard ID, UUID, slug, or permalink key/URL
(/dashboard/p/<key>/). "
+ "Omit with permalink_key."
),
),
]
permalink_key: str | None = Field(
default=None,
description=(
- "Key from a shared dashboard URL such as "
- "'/superset/dashboard/p/<key>/'. Resolves the dashboard and
includes "
- "the shared active-tab and filter context in the layout response."
+ "Permalink key; resolves dashboard and preserves shared "
+ "active-tab/filter state."
+ ),
+ )
+ tabs_only: bool = Field(
+ default=False,
+ description=(
+ "Tab tree only (ID, name, parent, depth, chart_count), in
tab_tree."
+ ),
+ )
+ tab: str | None = Field(
+ default=None,
+ description=(
+ "Tab subtree by ID or exact title (case-sensitive); IDs win over
titles."
+ ),
+ )
+ untabbed_only: bool = Field(
+ default=False,
+ description=(
+ "Only charts outside every tab; cannot combine with tab or
tabs_only."
),
)
@@ -1519,6 +1531,39 @@ class DashboardTab(BaseModel):
)
+class DashboardTabSummary(BaseModel):
+ """Compact tab-tree entry without chart IDs or chart positions."""
+
+ 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")
+ depth: int = Field(
+ ..., description="Tab nesting depth; top-level tabs have depth 0"
+ )
+ chart_count: int = Field(
+ ...,
+ description="Distinct chart IDs directly or indirectly under this tab",
+ )
+
+
+class DashboardLayoutScope(BaseModel):
+ """Scope applied to a get_dashboard_layout response."""
+
+ tabs_only: bool = Field(
+ False,
+ description=(
+ "True when chart positions were omitted by request; charts is then
"
+ "empty regardless of what the dashboard contains."
+ ),
+ )
+ tab_id: str | None = Field(
+ None, description="Resolved ID of the tab the response is limited to"
+ )
+ untabbed_only: bool = Field(
+ False, description="True when only charts outside every tab are
returned"
+ )
+
+
class DashboardLayout(BaseModel):
"""Parsed layout data for a dashboard, derived from position_json."""
@@ -1528,12 +1573,38 @@ class DashboardLayout(BaseModel):
tabs: List[DashboardTab] = Field(
default_factory=list,
description=(
- "Tabs declared in the dashboard layout (empty for untabbed
dashboards)"
+ "Tabs declared in the dashboard layout, limited to the selected "
+ "subtree when scoped by tab. Empty for untabbed dashboards and
when "
+ "tabs_only or untabbed_only is requested."
+ ),
+ )
+ tab_tree: List[DashboardTabSummary] = Field(
+ default_factory=list,
+ description=(
+ "Compact tab tree without chart IDs or positions; populated only
when "
+ "tabs_only is requested."
),
)
charts: List[ChartPosition] = Field(
default_factory=list,
- description="Charts placed in the dashboard layout with their tab
context",
+ description=(
+ "Charts placed in the dashboard layout with their tab context,
limited "
+ "to the requested scope. Always empty when tabs_only is requested;
"
+ "see scope."
+ ),
+ )
+ untabbed_chart_count: int = Field(
+ 0,
+ description=(
+ "Count of distinct charts outside every tab in the full layout; "
+ "untabbed_only lists each placement."
+ ),
+ )
+ scope: DashboardLayoutScope | None = Field(
+ None,
+ description=(
+ "Scope applied to tabs, tab_tree, and charts; None for the full
layout."
+ ),
)
has_layout: bool = Field(
default=False,
@@ -2080,12 +2151,18 @@ 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)
+ untabbed_chart_ids = {
+ chart.chart_id
+ for chart in charts
+ if chart.tab_id is None and chart.chart_id is not None
+ }
return DashboardLayout(
id=dashboard.id,
dashboard_title=dashboard.dashboard_title or "Untitled",
uuid=str(dashboard.uuid) if dashboard.uuid else None,
tabs=tabs,
charts=charts,
+ untabbed_chart_count=len(untabbed_chart_ids),
has_layout=bool(position_json_str),
)
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
b/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
index facfcad3a76..7a7e710f048 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
@@ -39,6 +39,9 @@ from superset.mcp_service.dashboard.schemas import (
dashboard_layout_serializer,
DashboardError,
DashboardLayout,
+ DashboardLayoutScope,
+ DashboardTab,
+ DashboardTabSummary,
GetDashboardLayoutRequest,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
@@ -46,6 +49,146 @@ from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
+def _resolve_tab(
+ tabs: list[DashboardTab], selector: str
+) -> DashboardTab | DashboardError:
+ """Match a tab by ID first, then by exact title."""
+ if not selector.strip():
+ return DashboardError.create(
+ "tab must not be blank. Omit tab for the full layout or use "
+ "tabs_only=true to discover tab IDs and titles.",
+ "tab_not_found",
+ )
+ if not tabs:
+ return DashboardError.create(
+ "This dashboard has no tabs. Omit tab to get the full layout.",
+ "tab_not_found",
+ )
+ matches = [tab for tab in tabs if tab.id == selector] or [
+ tab for tab in tabs if tab.name == selector
+ ]
+ if not matches:
+ return DashboardError.create(
+ "Tab not found. Use tabs_only=true to discover tab IDs and
titles.",
+ "tab_not_found",
+ )
+ if len(matches) > 1:
+ return DashboardError.create(
+ "Multiple tabs have that title. Use tabs_only=true to discover "
+ "their IDs, then pass a unique tab ID.",
+ "ambiguous_tab",
+ )
+ return matches[0]
+
+
+def _subtree_ids(tab_id: str, children: dict[str | None, list[str]]) ->
set[str]:
+ """Return a tab ID and the IDs of every tab nested under it."""
+ selected_ids: set[str] = set()
+ pending = [tab_id]
+ while pending:
+ current = pending.pop()
+ if current not in selected_ids:
+ selected_ids.add(current)
+ pending.extend(children.get(current, []))
+ return selected_ids
+
+
+def _tab_summaries(
+ tabs: list[DashboardTab], children: dict[str | None, list[str]]
+) -> list[DashboardTabSummary]:
+ """Summarize tabs with absolute depths from the full dashboard tree."""
+ # The parser only emits reachable tabs and assigns each tab a single
+ # enclosing parent, so walking from the top-level tabs reaches every tab.
+ depths: dict[str, int] = {}
+ stack = [(tab_id, 0) for tab_id in children.get(None, [])]
+ while stack:
+ tab_id, depth = stack.pop()
+ depths[tab_id] = depth
+ stack.extend((child, depth + 1) for child in children.get(tab_id, []))
+ return [
+ DashboardTabSummary(
+ id=tab.id,
+ name=tab.name,
+ parent_tab_id=tab.parent_tab_id,
+ depth=depths[tab.id],
+ chart_count=len(tab.chart_ids),
+ )
+ for tab in tabs
+ ]
+
+
+def _scope_layout(
+ layout: DashboardLayout, request: GetDashboardLayoutRequest
+) -> DashboardLayout | DashboardError:
+ """Project the parsed layout without changing permalink or ancestry
context.
+
+ Scoping only ever removes tabs and chart placements from the parsed layout,
+ so a scoped response never contains a chart the full layout would not.
+ """
+ if request.untabbed_only and (request.tabs_only or request.tab is not
None):
+ return DashboardError.create(
+ "untabbed_only cannot be combined with tab or tabs_only. "
+ "Drop tab and tabs_only to retrieve charts outside every tab, "
+ "or drop untabbed_only to use a tab scope.",
+ "invalid_scope",
+ )
+
+ if not request.tabs_only and request.tab is None and not
request.untabbed_only:
+ return layout
+
+ if request.untabbed_only:
+ return layout.model_copy(
+ update={
+ "tabs": [],
+ "charts": [chart for chart in layout.charts if chart.tab_id is
None],
+ "scope": DashboardLayoutScope(untabbed_only=True),
+ }
+ )
+
+ tabs = layout.tabs
+ if request.tabs_only and not tabs:
+ return DashboardError.create(
+ "This dashboard has no tabs. Use untabbed_only=true without tab "
+ "or tabs_only to get charts outside every tab, or omit the scope "
+ "options to get the full layout.",
+ "tab_not_found",
+ )
+ children: dict[str | None, list[str]] = {}
+ for tab in tabs:
+ children.setdefault(tab.parent_tab_id, []).append(tab.id)
+
+ selected_tab_id: str | None = None
+ if request.tab is not None:
+ selected = _resolve_tab(tabs, request.tab)
+ if isinstance(selected, DashboardError):
+ return selected
+ selected_tab_id = selected.id
+ subtree_ids = _subtree_ids(selected.id, children)
+ tabs = [tab for tab in tabs if tab.id in subtree_ids]
+
+ scope = DashboardLayoutScope(tabs_only=request.tabs_only,
tab_id=selected_tab_id)
+ if request.tabs_only:
+ return layout.model_copy(
+ update={
+ "tabs": [],
+ "tab_tree": _tab_summaries(tabs, children),
+ "charts": [],
+ "scope": scope,
+ }
+ )
+
+ selected_ids: set[str] = {tab.id for tab in tabs}
+ return layout.model_copy(
+ update={
+ "tabs": tabs,
+ "charts": [
+ chart for chart in layout.charts if chart.tab_id in
selected_ids
+ ],
+ "scope": scope,
+ }
+ )
+
+
@tool(
tags=["discovery"],
class_permission_name="Dashboard",
@@ -68,6 +211,15 @@ async def get_dashboard_layout(
explain which charts live under which tab, or to locate a chart by
its parent tab).
+ For large dashboards, pass ``tabs_only=true`` to discover the complete tab
+ tree with nesting depths and descendant chart counts, without chart
positions.
+ Then pass ``tab="<ID or exact title>"`` to retrieve only that tab's subtree
+ and chart positions. IDs take precedence; duplicate titles require an ID.
+ Combine both options to summarize a subtree. Pass ``untabbed_only=true``
+ for charts outside every tab.
+ Parent IDs and tab paths remain relative to the full dashboard, and
+ permalink state is preserved unchanged.
+
If the user gives you a shared URL containing ``/dashboard/p/<key>/``, pass
the URL or bare key as ``identifier`` (or use ``permalink_key`` alone). The
response identifies the active tab and includes the shared filter state.
@@ -129,14 +281,19 @@ async def get_dashboard_layout(
"permalink_key belongs to a different dashboard;
ignoring "
"its active-tab and filter state."
)
+ result = _scope_layout(result, request)
+
+ if isinstance(result, DashboardLayout):
await ctx.info(
"Dashboard layout retrieved: id=%s, tab_count=%s,
chart_count=%s, "
- "has_layout=%s"
+ "untabbed_chart_count=%s, has_layout=%s, scope=%s"
% (
result.id,
- len(result.tabs),
+ len(result.tab_tree) if request.tabs_only else
len(result.tabs),
len(result.charts),
+ result.untabbed_chart_count,
result.has_layout,
+ result.scope.model_dump() if result.scope else None,
)
)
else:
diff --git a/superset/mcp_service/middleware.py
b/superset/mcp_service/middleware.py
index 5617225f425..339d44484bf 100644
--- a/superset/mcp_service/middleware.py
+++ b/superset/mcp_service/middleware.py
@@ -70,6 +70,7 @@ from superset.mcp_service.utils.response_size_utils import (
format_size_limit_error,
get_response_size_bytes,
INFO_TOOLS,
+ RESPONSE_AWARE_SUGGESTION_TOOLS,
string_clip_chars,
STRING_FIELD_TRUNCATION_TOOLS,
truncate_oversized_response,
@@ -2116,7 +2117,11 @@ class ResponseSizeGuardMiddleware(Middleware):
params=params,
actual_bytes=actual_bytes,
max_bytes=self.max_bytes,
- response=None,
+ response=(
+ self._extract_payload_from_tool_result(response)
+ if tool_name in RESPONSE_AWARE_SUGGESTION_TOOLS
+ else None
+ ),
)
)
diff --git a/superset/mcp_service/utils/response_size_utils.py
b/superset/mcp_service/utils/response_size_utils.py
index 09cee73d368..91d72459115 100644
--- a/superset/mcp_service/utils/response_size_utils.py
+++ b/superset/mcp_service/utils/response_size_utils.py
@@ -116,6 +116,10 @@ def extract_query_params(params: Dict[str, Any] | None) ->
Dict[str, Any]:
"filters",
# Search
"search",
+ # Dashboard layout scope
+ "tab",
+ "tabs_only",
+ "untabbed_only",
]
return {k: params[k] for k in extract_keys if k in params}
@@ -277,6 +281,175 @@ def _identify_large_fields(response: ToolResponse) ->
List[str]:
return large_fields
+# Tools whose oversized-response suggestions depend on the blocked payload, so
+# the size guard passes the parsed payload to format_size_limit_error.
+RESPONSE_AWARE_SUGGESTION_TOOLS = frozenset({"get_dashboard_layout"})
+
+_MAX_SUGGESTED_TAB_IDS = 5
+_MAX_SUGGESTED_TAB_ID_CHARS = 64
+
+
+def _format_tab_ids(tab_ids: List[str]) -> str:
+ """Quote a bounded sample of tab IDs for inclusion in a suggestion."""
+ from superset.utils import json
+
+ sample = [
+ json.dumps(tab_id[:_MAX_SUGGESTED_TAB_ID_CHARS])
+ for tab_id in tab_ids[:_MAX_SUGGESTED_TAB_IDS]
+ ]
+ suffix = ", ..." if len(tab_ids) > _MAX_SUGGESTED_TAB_IDS else ""
+ return ", ".join(sample) + suffix
+
+
+def _layout_tab_entries(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Return the tab entries (full tabs or tab_tree summaries) in a
payload."""
+ entries: List[Dict[str, Any]] = []
+ for key in ("tabs", "tab_tree"):
+ value = payload.get(key)
+ if isinstance(value, list):
+ entries.extend(
+ entry
+ for entry in value
+ if isinstance(entry, dict) and isinstance(entry.get("id"), str)
+ )
+ return entries
+
+
+def _selected_tab_suggestions(
+ selected_tab: str,
+ tabs: List[Dict[str, Any]],
+ *,
+ tabs_only: bool,
+ known: bool,
+ list_charts_hint: str,
+) -> List[str]:
+ """Suggest how to narrow a response already scoped to one tab."""
+ child_ids = [tab["id"] for tab in tabs if tab.get("parent_tab_id") ==
selected_tab]
+ if child_ids:
+ suggestions = [
+ f"Pass a nested tab ID ({_format_tab_ids(child_ids)}) as tab to "
+ "narrow the response further."
+ ]
+ if not tabs_only:
+ suggestions.append(
+ "Add tabs_only=true to the same tab to see its subtree with "
+ "chart counts but without chart positions."
+ )
+ return suggestions
+ if not known:
+ return [
+ "If this tab has nested tabs, pass one of their IDs (see tab_tree "
+ "from tabs_only=true) as tab. A tab without nested tabs cannot be "
+ "narrowed further by tab.",
+ list_charts_hint,
+ ]
+ if tabs_only:
+ return [
+ "This tab has no nested tabs, so its tab tree cannot be narrowed "
+ "further by tab."
+ ]
+ return [
+ "This tab has no nested tabs, so tab cannot narrow it further. "
+ "Add tabs_only=true for its chart count only.",
+ list_charts_hint,
+ ]
+
+
+def _unscoped_layout_suggestions(
+ tabs: List[Dict[str, Any]],
+ untabbed_count: int,
+ *,
+ known: bool,
+ list_charts_hint: str,
+) -> List[str]:
+ """Suggest how to narrow a full dashboard layout."""
+ if known and not tabs:
+ return [
+ "This dashboard has no tabs, so tab and tabs_only cannot narrow
its "
+ "layout. " + list_charts_hint
+ ]
+ suggestions = [
+ "Call get_dashboard_layout with tabs_only=true to discover the tab
tree "
+ "without chart positions.",
+ 'Then pass tab="<ID or title>" to get only that tab\'s subtree and
chart '
+ "positions; use a nested tab ID for a smaller response.",
+ ]
+ if untabbed_count or not known:
+ suggestions.append(
+ "Pass untabbed_only=true to get the charts placed outside every
tab"
+ + (
+ f" ({untabbed_count} distinct "
+ f"{'chart' if untabbed_count == 1 else 'charts'})."
+ if untabbed_count
+ else "."
+ )
+ )
+ return suggestions
+
+
+def _dashboard_layout_suggestions(
+ query_params: Dict[str, Any], response: ToolResponse | None
+) -> List[str]:
+ """Suggest the next narrower get_dashboard_layout scope.
+
+ The blocked payload, when available, tells whether the dashboard has tabs,
+ whether the selected tab has nested tabs, and whether charts sit outside
+ every tab. Without it, suggestions depend on the request parameters only.
+ """
+ payload: Dict[str, Any] = {}
+ if isinstance(response, BaseModel):
+ payload = response.model_dump()
+ elif isinstance(response, dict):
+ payload = response
+ known = bool(payload)
+ tabs = _layout_tab_entries(payload)
+ raw_scope = payload.get("scope")
+ scope: Dict[str, Any] = raw_scope if isinstance(raw_scope, dict) else {}
+ dashboard_id = payload.get("id")
+ list_charts_hint = (
+ "Use list_charts with filters=[{col: 'dashboards', opr: 'eq', value: "
+ f"{dashboard_id if isinstance(dashboard_id, int) else '<ID>'}}}] and a
"
+ "small page_size to page through chart metadata without layout
positions."
+ )
+ tabs_only = bool(query_params.get("tabs_only"))
+
+ if query_params.get("untabbed_only"):
+ return [
+ "The charts outside every tab cannot be narrowed further by "
+ "get_dashboard_layout. " + list_charts_hint
+ ]
+
+ selected_tab = scope.get("tab_id") or query_params.get("tab")
+ if isinstance(selected_tab, str):
+ return _selected_tab_suggestions(
+ selected_tab,
+ tabs,
+ tabs_only=tabs_only,
+ known=known,
+ list_charts_hint=list_charts_hint,
+ )
+
+ if tabs_only:
+ top_level_ids = [tab["id"] for tab in tabs if tab.get("depth") == 0]
+ example = (
+ f" (top-level tabs: {_format_tab_ids(top_level_ids)})"
+ if top_level_ids
+ else ""
+ )
+ return [
+ 'Keep tabs_only=true and pass tab="<top-level tab ID>" to get one '
+ f"branch of the tab tree at a time{example}."
+ ]
+
+ raw_untabbed = payload.get("untabbed_chart_count")
+ return _unscoped_layout_suggestions(
+ tabs,
+ raw_untabbed if isinstance(raw_untabbed, int) else 0,
+ known=known,
+ list_charts_hint=list_charts_hint,
+ )
+
+
def _get_tool_specific_suggestions(
tool_name: str,
query_params: Dict[str, Any],
@@ -319,6 +492,9 @@ def _get_tool_specific_suggestions(
"to shorten the generated query."
)
+ elif tool_name == "get_dashboard_layout":
+ suggestions.extend(_dashboard_layout_suggestions(query_params,
response))
+
elif tool_name in ("get_chart_info", "get_dashboard_info",
"get_dataset_info"):
suggestions.append(
f"For {tool_name}, use 'select_columns' to fetch only specific
metadata "
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
index 4a3af4eb2df..7c575b0125d 100644
--- 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
@@ -17,10 +17,11 @@
"""Unit tests for the MCP get_dashboard_layout tool."""
+from typing import Any
from unittest.mock import Mock, patch
import pytest
-from fastmcp import Client
+from fastmcp import Client, FastMCP
from superset.mcp_service.app import mcp
from superset.mcp_service.dashboard.schemas import (
@@ -261,9 +262,10 @@ async def test_get_dashboard_layout_not_found(mock_find,
mcp_server):
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@patch("superset.mcp_service.dashboard.permalink.get_dashboard_permalink")
[email protected]("options", [{}, {"tabs_only": True}, {"tab":
"TAB-1"}])
@pytest.mark.asyncio
async def test_get_dashboard_layout_resolves_shared_permalink(
- mock_permalink, mock_find, mcp_server
+ mock_permalink, mock_find, mcp_server, options
):
mock_permalink.return_value = (
"shared-key",
@@ -284,7 +286,8 @@ async def
test_get_dashboard_layout_resolves_shared_permalink(
"get_dashboard_layout",
{
"request": {
- "identifier":
"https://example.test/superset/dashboard/p/shared-key/"
+ "identifier":
"https://example.test/superset/dashboard/p/shared-key/",
+ **options,
}
},
)
@@ -295,6 +298,27 @@ async def
test_get_dashboard_layout_resolves_shared_permalink(
assert data["is_permalink_state"] is True
assert data["filter_state"]["activeTabs"] == [_wrapped("TAB-2")]
assert mock_find.call_args_list[-1].args == (42,)
+ # The explicit scope applies on top of the preserved permalink state, even
+ # when it selects a tab other than the permalink's active tab.
+ if options.get("tabs_only"):
+ assert data["scope"] == {
+ "tabs_only": True,
+ "tab_id": None,
+ "untabbed_only": False,
+ }
+ assert [tab["id"] for tab in data["tab_tree"]] == ["TAB-1", "TAB-2"]
+ assert data["charts"] == []
+ elif options.get("tab"):
+ assert data["scope"] == {
+ "tabs_only": False,
+ "tab_id": "TAB-1",
+ "untabbed_only": False,
+ }
+ assert [tab["id"] for tab in data["tabs"]] == ["TAB-1"]
+ assert [chart["chart_id"] for chart in data["charts"]] == [10]
+ else:
+ assert data["scope"] is None
+ assert [chart["chart_id"] for chart in data["charts"]] == [10, 20]
@patch("superset.mcp_service.dashboard.permalink.get_dashboard_permalink")
@@ -416,3 +440,717 @@ def
test_get_dashboard_info_omitted_fields_references_layout_tool():
json_metadata_str=None, position_json_str='{"ROOT_ID": {}}'
)
assert "get_dashboard_layout" in omitted["position_json"]
+
+
[email protected]
+def large_tabbed_layout() -> str:
+ """Generate 114 nested tabs and 404 charts without storing a large
fixture."""
+ position: dict[str, Any] = {
+ "ROOT_ID": {"type": "ROOT", "children": ["TABS-root"]},
+ "TABS-root": {
+ "type": "TABS",
+ "children": [f"TAB-group-{i}" for i in range(14)],
+ },
+ }
+ for group in range(14):
+ position[f"TAB-group-{group}"] = {
+ "type": "TAB",
+ "meta": {"text": f"Group {group}"},
+ "children": [f"TABS-group-{group}"],
+ }
+ position[f"TABS-group-{group}"] = {
+ "type": "TABS",
+ "children": [f"TAB-leaf-{i}" for i in range(group, 100, 14)],
+ }
+ for leaf in range(100):
+ position[f"TAB-leaf-{leaf}"] = {
+ "type": "TAB",
+ "meta": {"text": f"Detail {leaf}"},
+ "children": [f"CHART-{i}" for i in range(leaf * 4, leaf * 4 + 4)],
+ }
+ position["ROW-shared"] = {
+ "type": "ROW",
+ "children": [f"CHART-{i}" for i in range(400, 404)],
+ }
+ position["ROOT_ID"]["children"].append("ROW-shared")
+ for chart in range(404):
+ position[f"CHART-{chart}"] = {
+ "type": "CHART",
+ "meta": {
+ "chartId": chart,
+ "sliceName": f"Chart {chart}: " + "Detailed metric description
" * 12,
+ "width": 6,
+ "height": 40,
+ },
+ }
+ return json.dumps(position)
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_tabs_only_large_tree(
+ mock_find: Mock, mcp_server: FastMCP, large_tabbed_layout: str
+) -> None:
+ """All tabs fit comfortably under the guard without any chart positions."""
+ from fastmcp.tools.tool import ToolResult
+
+ from superset.mcp_service.utils.response_size_utils import
get_response_size_bytes
+
+ mock_find.return_value =
_build_dashboard_mock(position_json=large_tabbed_layout)
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1, "tabs_only":
True}}
+ )
+ data = json.loads(result.content[0].text)
+ assert len(data["tab_tree"]) == 114
+ assert data["tabs"] == []
+ assert data["charts"] == []
+ assert data["untabbed_chart_count"] == 4
+ assert data["scope"] == {"tabs_only": True, "tab_id": None,
"untabbed_only": False}
+ tabs = {tab["id"]: tab for tab in data["tab_tree"]}
+ assert tabs["TAB-group-0"] == {
+ "id": "TAB-group-0",
+ "name": "Group 0",
+ "parent_tab_id": None,
+ "depth": 0,
+ "chart_count": 32,
+ }
+ assert tabs["TAB-leaf-0"] == {
+ "id": "TAB-leaf-0",
+ "name": "Detail 0",
+ "parent_tab_id": "TAB-group-0",
+ "depth": 1,
+ "chart_count": 4,
+ }
+ assert all("chart_ids" not in tab for tab in data["tab_tree"])
+ # Master guards exact UTF-8 bytes; also bound the legacy bytes/4 estimate.
+ response_bytes =
get_response_size_bytes(ToolResult(content=result.content))
+ assert response_bytes < 25_000
+ assert response_bytes / 4 < 20_000 / 3
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected](
+ "tab, tab_id",
+ [
+ ("TAB-group-0", "TAB-group-0"),
+ ("Group 0", "TAB-group-0"),
+ ("TAB-leaf-0", "TAB-leaf-0"),
+ ("Detail 0", "TAB-leaf-0"),
+ ],
+)
[email protected]
+async def test_layout_tab_filter_large_tree(
+ mock_find: Mock,
+ mcp_server: FastMCP,
+ large_tabbed_layout: str,
+ tab: str,
+ tab_id: str,
+) -> None:
+ """IDs and exact titles select only a tab and its descendants."""
+ mock_find.return_value =
_build_dashboard_mock(position_json=large_tabbed_layout)
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1, "tab": tab}}
+ )
+ data = json.loads(result.content[0].text)
+ leaves = range(0, 100, 14) if tab_id == "TAB-group-0" else [0]
+ expected_chart_ids = {leaf * 4 + offset for leaf in leaves for offset in
range(4)}
+ assert {chart["chart_id"] for chart in data["charts"]} ==
expected_chart_ids
+ assert len(data["tabs"]) == (9 if len(expected_chart_ids) == 32 else 1)
+ assert all(
+ chart["width"] == 6 and chart["height"] == 40 for chart in
data["charts"]
+ )
+ assert data["charts"][0]["tab_path"] == ["Group 0", "Detail 0"]
+ assert data["scope"] == {
+ "tabs_only": False,
+ "tab_id": tab_id,
+ "untabbed_only": False,
+ }
+ assert data["tab_tree"] == []
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_default_response_unchanged(
+ mock_find: Mock, mcp_server: FastMCP
+) -> None:
+ """Omitting scope options preserves the complete existing response
shape."""
+ dashboard = _build_dashboard_mock(position_json=_tabbed_layout())
+ mock_find.return_value = dashboard
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1}}
+ )
+ assert json.loads(result.content[0].text) == {
+ "id": 1,
+ "dashboard_title": "Test Dashboard",
+ "uuid": "dashboard-uuid-1",
+ "has_layout": True,
+ "permalink_key": None,
+ "filter_state": None,
+ "is_permalink_state": False,
+ "tab_tree": [],
+ "untabbed_chart_count": 0,
+ "scope": None,
+ "tabs": [
+ {
+ "id": "TAB-1",
+ "name": "Overview",
+ "parent_tab_id": None,
+ "chart_ids": [10],
+ },
+ {
+ "id": "TAB-2",
+ "name": "Details",
+ "parent_tab_id": None,
+ "chart_ids": [20],
+ },
+ ],
+ "charts": [
+ {
+ "chart_id": 10,
+ "slice_name": "Top KPIs",
+ "tab_id": "TAB-1",
+ "tab_path": ["Overview"],
+ "width": 6,
+ "height": 40,
+ },
+ {
+ "chart_id": 20,
+ "slice_name": "Detail Chart",
+ "tab_id": "TAB-2",
+ "tab_path": ["Details"],
+ "width": 12,
+ "height": 60,
+ },
+ ],
+ }
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_tabs_only_with_filter(
+ mock_find: Mock, mcp_server: FastMCP, large_tabbed_layout: str
+) -> None:
+ """Summary mode composes with tab scoping and keeps absolute depth."""
+ mock_find.return_value =
_build_dashboard_mock(position_json=large_tabbed_layout)
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout",
+ {"request": {"identifier": 1, "tabs_only": True, "tab": "Detail
0"}},
+ )
+ data = json.loads(result.content[0].text)
+ assert data["charts"] == []
+ assert data["tabs"] == []
+ assert len(data["tab_tree"]) == 1
+ assert data["tab_tree"][0]["depth"] == 1
+ assert data["tab_tree"][0]["parent_tab_id"] == "TAB-group-0"
+ assert data["scope"] == {
+ "tabs_only": True,
+ "tab_id": "TAB-leaf-0",
+ "untabbed_only": False,
+ }
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected](
+ "tab, error_type", [("Missing", "tab_not_found"), ("Same",
"ambiguous_tab")]
+)
[email protected]
+async def test_layout_tab_filter_errors(
+ mock_find: Mock, mcp_server: FastMCP, tab: str, error_type: str
+) -> None:
+ """Do not silently choose among duplicate titles or fall back to a full
layout."""
+ position = json.loads(_tabbed_layout())
+ position["TAB-1"]["meta"]["text"] = "Same"
+ position["TAB-2"]["meta"]["text"] = "Same"
+ mock_find.return_value =
_build_dashboard_mock(position_json=json.dumps(position))
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1, "tab": tab}}
+ )
+ data = json.loads(result.content[0].text)
+ assert data["error_type"] == error_type
+ assert "tabs_only" in data["error"]
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_tab_id_precedes_title_and_filters_placements(
+ mock_find: Mock, mcp_server: FastMCP
+) -> None:
+ """A chart reused in another tab must not leak that tab's placement."""
+ position = json.loads(_tabbed_layout())
+ position["TAB-2"]["meta"]["text"] = "TAB-1"
+ position["CHART-b"]["meta"]["chartId"] = 10
+ mock_find.return_value =
_build_dashboard_mock(position_json=json.dumps(position))
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1, "tab":
"TAB-1"}}
+ )
+ data = json.loads(result.content[0].text)
+ assert [tab["id"] for tab in data["tabs"]] == ["TAB-1"]
+ assert len(data["charts"]) == 1
+ assert data["charts"][0]["tab_id"] == "TAB-1"
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]("position", [None, "{}", "invalid", _simple_layout()])
[email protected]
+async def test_layout_tabs_only_without_tabs(
+ mock_find: Mock, mcp_server: FastMCP, position: str | None
+) -> None:
+ """Tab discovery without tabs offers recovery instead of an empty
layout."""
+ mock_find.return_value = _build_dashboard_mock(position_json=position)
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1, "tabs_only":
True}}
+ )
+ data = json.loads(result.content[0].text)
+ assert data["error_type"] == "tab_not_found"
+ assert "no tabs" in data["error"]
+ assert "untabbed_only=true" in data["error"]
+ assert "full layout" in data["error"]
+ assert "charts" not in data
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]("tab", ["", " "])
[email protected]
+async def test_layout_rejects_blank_tab(
+ mock_find: Mock, mcp_server: FastMCP, tab: str
+) -> None:
+ """Blank selectors return actionable errors through the MCP client."""
+ mock_find.return_value =
_build_dashboard_mock(position_json=_tabbed_layout())
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1, "tab": tab}}
+ )
+ data = json.loads(result.content[0].text)
+ assert data["error_type"] == "tab_not_found"
+ assert "tab must not be blank" in data["error"]
+ assert "tabs_only=true" in data["error"]
+ assert "charts" not in data
+
+
+def _mixed_layout() -> str:
+ """A ROOT/GRID layout with one chart in a ROW above the tabs."""
+ position = json.loads(_tabbed_layout())
+ position["ROOT_ID"]["children"] = ["GRID_ID"]
+ position["GRID_ID"] = {
+ "type": "GRID",
+ "id": "GRID_ID",
+ "parents": ["ROOT_ID"],
+ "children": ["ROW-top", "TABS-1"],
+ }
+ position["ROW-top"] = {
+ "type": "ROW",
+ "id": "ROW-top",
+ "parents": ["ROOT_ID", "GRID_ID"],
+ "children": ["CHART-u"],
+ "meta": {},
+ }
+ position["CHART-u"] = {
+ "type": "CHART",
+ "id": "CHART-u",
+ "parents": ["ROOT_ID", "GRID_ID", "ROW-top"],
+ "children": [],
+ "meta": {"chartId": 30, "sliceName": "Headline KPI", "width": 12},
+ }
+ return json.dumps(position)
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_untabbed_charts_are_reachable(
+ mock_find: Mock, mcp_server: FastMCP
+) -> None:
+ """Charts outside every tab are counted in every scope and selectable
alone."""
+ mock_find.return_value =
_build_dashboard_mock(position_json=_mixed_layout())
+ async with Client(mcp_server) as client:
+ full = json.loads(
+ (
+ await client.call_tool(
+ "get_dashboard_layout", {"request": {"identifier": 1}}
+ )
+ )
+ .content[0]
+ .text
+ )
+ summary = json.loads(
+ (
+ await client.call_tool(
+ "get_dashboard_layout",
+ {"request": {"identifier": 1, "tabs_only": True}},
+ )
+ )
+ .content[0]
+ .text
+ )
+ untabbed = json.loads(
+ (
+ await client.call_tool(
+ "get_dashboard_layout",
+ {"request": {"identifier": 1, "untabbed_only": True}},
+ )
+ )
+ .content[0]
+ .text
+ )
+
+ assert {chart["chart_id"] for chart in full["charts"]} == {10, 20, 30}
+ assert full["untabbed_chart_count"] == 1
+ assert summary["untabbed_chart_count"] == 1
+ assert [tab["id"] for tab in summary["tab_tree"]] == ["TAB-1", "TAB-2"]
+ assert untabbed["untabbed_chart_count"] == 1
+ assert untabbed["tabs"] == []
+ assert untabbed["scope"] == {
+ "tabs_only": False,
+ "tab_id": None,
+ "untabbed_only": True,
+ }
+ assert untabbed["charts"] == [
+ {
+ "chart_id": 30,
+ "slice_name": "Headline KPI",
+ "tab_id": None,
+ "tab_path": [],
+ "width": 12,
+ "height": None,
+ }
+ ]
+ # Scoping only removes placements; it never adds one the full layout lacks.
+ assert all(chart in full["charts"] for chart in untabbed["charts"])
+
+
+def test_layout_untabbed_count_is_distinct_charts() -> None:
+ """untabbed_chart_count counts distinct charts; untabbed_only lists
placements."""
+ from superset.mcp_service.utils.response_size_utils import (
+ format_size_limit_error,
+ )
+
+ position = json.loads(_mixed_layout())
+ position["ROW-top"]["children"] = ["CHART-u", "CHART-u2", "CHART-none"]
+ position["CHART-u2"] = {
+ **position["CHART-u"],
+ "id": "CHART-u2",
+ }
+ position["CHART-none"] = {
+ "type": "CHART",
+ "id": "CHART-none",
+ "parents": ["ROOT_ID", "GRID_ID", "ROW-top"],
+ "children": [],
+ "meta": {"sliceName": "Unsaved"},
+ }
+ position_json = json.dumps(position)
+
+ full = _scoped_payload(position_json)
+ untabbed = _scoped_payload(position_json, untabbed_only=True)
+ assert full["untabbed_chart_count"] == 1
+ assert [chart["chart_id"] for chart in untabbed["charts"]] == [30, 30,
None]
+
+ message = format_size_limit_error(
+ tool_name="get_dashboard_layout",
+ params={"request": {"identifier": 1}},
+ actual_bytes=200_000,
+ max_bytes=100_000,
+ response=full,
+ )
+ assert "outside every tab (1 distinct chart)." in message
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_untabbed_only_on_untabbed_dashboard(
+ mock_find: Mock, mcp_server: FastMCP
+) -> None:
+ """On a dashboard without tabs, untabbed_only is the full chart list."""
+ 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, "untabbed_only": True}},
+ )
+ data = json.loads(result.content[0].text)
+ assert [chart["chart_id"] for chart in data["charts"]] == [42]
+ assert data["untabbed_chart_count"] == 1
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_tab_on_untabbed_dashboard_does_not_loop(
+ mock_find: Mock, mcp_server: FastMCP
+) -> None:
+ """Selecting a tab on an untabbed dashboard must not point back at
tabs_only."""
+ 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, "tab":
"TAB-1"}}
+ )
+ data = json.loads(result.content[0].text)
+ assert data["error_type"] == "tab_not_found"
+ assert "no tabs" in data["error"]
+ assert "tabs_only" not in data["error"]
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected](
+ "options", [{"tabs_only": True}, {"tab": "TAB-1"}, {"tab": "X",
"tabs_only": True}]
+)
[email protected]
+async def test_layout_rejects_untabbed_only_with_tab_options(
+ mock_find: Mock, mcp_server: FastMCP, options: dict[str, Any]
+) -> None:
+ """Conflicting scopes reach the client as actionable errors, not
validation text."""
+ mock_find.return_value =
_build_dashboard_mock(position_json=_mixed_layout())
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout",
+ {"request": {"identifier": 1, "untabbed_only": True, **options}},
+ )
+ data = json.loads(result.content[0].text)
+ assert data["error_type"] == "invalid_scope"
+ assert "untabbed_only cannot be combined" in data["error"]
+ assert "Drop tab and tabs_only" in data["error"]
+ assert "drop untabbed_only" in data["error"]
+ assert "charts" not in data
+
+
+def _scoped_payload(position_json: str, **options: Any) -> dict[str, Any]:
+ from superset.mcp_service.dashboard.schemas import (
+ dashboard_layout_serializer,
+ DashboardLayout,
+ GetDashboardLayoutRequest,
+ )
+ from superset.mcp_service.dashboard.tool.get_dashboard_layout import (
+ _scope_layout,
+ )
+
+ layout = _scope_layout(
+
dashboard_layout_serializer(_build_dashboard_mock(position_json=position_json)),
+ GetDashboardLayoutRequest(identifier=1, **options),
+ )
+ assert isinstance(layout, DashboardLayout)
+ return layout.model_dump(mode="json")
+
+
[email protected](
+ "layout_name, options, expected, unexpected",
+ [
+ (
+ "large",
+ {},
+ [
+ "tabs_only=true",
+ 'tab="<ID or title>"',
+ "untabbed_only=true",
+ "outside every tab (4 distinct charts)",
+ ],
+ [],
+ ),
+ (
+ "tabbed",
+ {},
+ ["tabs_only=true", 'tab="<ID or title>"'],
+ ["untabbed_only"],
+ ),
+ (
+ "simple",
+ {},
+ ["no tabs", "list_charts", "'dashboards'", "value: 1"],
+ ["tabs_only=true", "untabbed_only"],
+ ),
+ (
+ "large",
+ {"tabs_only": True},
+ ["Keep tabs_only=true", '"TAB-group-0"', '"TAB-group-4", ...'],
+ ["TAB-leaf"],
+ ),
+ (
+ "large",
+ {"tab": "TAB-group-0"},
+ ['"TAB-leaf-0"', "nested tab ID", "Add tabs_only=true"],
+ ["no nested tabs"],
+ ),
+ (
+ "large",
+ {"tab": "TAB-group-0", "tabs_only": True},
+ ['"TAB-leaf-0"', "nested tab ID"],
+ ["Add tabs_only=true", "no nested tabs"],
+ ),
+ (
+ "large",
+ {"tab": "TAB-leaf-0"},
+ ["no nested tabs", "tab cannot narrow it", "list_charts"],
+ ["Pass a nested tab ID", 'tab="<ID or title>"'],
+ ),
+ (
+ "large",
+ {"tab": "Detail 0", "tabs_only": True},
+ ["no nested tabs", "cannot be narrowed further by tab"],
+ ["Pass a nested tab ID", "Keep tabs_only=true"],
+ ),
+ (
+ "mixed",
+ {"untabbed_only": True},
+ ["cannot be narrowed further", "list_charts"],
+ ["tabs_only=true", "Pass a nested tab ID"],
+ ),
+ ],
+)
+def test_layout_oversized_hint_follows_scope(
+ large_tabbed_layout: str,
+ layout_name: str,
+ options: dict[str, Any],
+ expected: list[str],
+ unexpected: list[str],
+) -> None:
+ """Each oversized scope suggests only a narrower scope that actually
exists."""
+ from superset.mcp_service.utils.response_size_utils import (
+ format_size_limit_error,
+ )
+
+ position_json = {
+ "large": large_tabbed_layout,
+ "tabbed": _tabbed_layout(),
+ "simple": _simple_layout(),
+ "mixed": _mixed_layout(),
+ }[layout_name]
+ message = format_size_limit_error(
+ tool_name="get_dashboard_layout",
+ params={"request": {"identifier": 1, **options}},
+ actual_bytes=200_000,
+ max_bytes=100_000,
+ response=_scoped_payload(position_json, **options),
+ )
+ for text in expected:
+ assert text in message
+ for text in unexpected:
+ assert text not in message
+
+
[email protected](
+ "options, expected",
+ [
+ ({}, ["tabs_only=true", "untabbed_only=true", "outside every tab."]),
+ ({"tabs_only": True}, ['pass tab="<top-level tab ID>"']),
+ ({"tab": "TAB-1"}, ["If this tab has nested tabs", "list_charts"]),
+ ({"untabbed_only": True}, ["cannot be narrowed further"]),
+ ],
+)
+def test_layout_oversized_hint_without_payload(
+ options: dict[str, Any], expected: list[str]
+) -> None:
+ """When the payload is unavailable, the hint relies on the request
alone."""
+ from superset.mcp_service.utils.response_size_utils import (
+ format_size_limit_error,
+ )
+
+ message = format_size_limit_error(
+ tool_name="get_dashboard_layout",
+ params={"request": {"identifier": 1, **options}},
+ actual_bytes=200_000,
+ max_bytes=100_000,
+ )
+ for text in expected:
+ assert text in message
+
+
[email protected](
+ "layout_name, expected, unexpected",
+ [
+ ("large", ["tabs_only=true", 'tab="<ID or title>"'], ["no tabs"]),
+ ("simple", ["no tabs", "list_charts"], ["tabs_only=true"]),
+ ],
+)
[email protected]
+async def test_layout_oversized_guard_hint(
+ large_tabbed_layout: str,
+ layout_name: str,
+ expected: list[str],
+ unexpected: list[str],
+) -> None:
+ """The real guard hands the blocked layout to the hint, so it knows the
tabs."""
+ from unittest.mock import AsyncMock
+
+ from fastmcp.exceptions import ToolError
+ from fastmcp.tools.tool import ToolResult
+ from mcp.types import TextContent
+
+ from superset.mcp_service.dashboard.schemas import
dashboard_layout_serializer
+ from superset.mcp_service.middleware import ResponseSizeGuardMiddleware
+
+ position_json = large_tabbed_layout if layout_name == "large" else
_simple_layout()
+ layout = dashboard_layout_serializer(
+ _build_dashboard_mock(position_json=position_json)
+ )
+ response = ToolResult(
+ content=[TextContent(type="text", text=layout.model_dump_json())]
+ )
+ middleware = ResponseSizeGuardMiddleware(max_bytes=100)
+ context = Mock()
+ context.message.name = "get_dashboard_layout"
+ context.message.arguments = {"request": {"identifier": 1}}
+ with (
+ patch("superset.mcp_service.middleware.get_user_id", return_value=1),
+ patch("superset.mcp_service.middleware.event_logger"),
+ pytest.raises(ToolError) as exc_info,
+ ):
+ await middleware.on_call_tool(context,
AsyncMock(return_value=response))
+ message = str(exc_info.value)
+ assert "Response too large" in message
+ for text in expected:
+ assert text in message
+ for text in unexpected:
+ assert text not in message
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]
+async def test_layout_logs_scoped_counts(
+ mock_find: Mock, mcp_server: FastMCP, large_tabbed_layout: str
+) -> None:
+ """The retrieval log reports what was returned, not the unscoped layout."""
+ mock_find.return_value =
_build_dashboard_mock(position_json=large_tabbed_layout)
+ messages: list[str] = []
+
+ async def log_handler(message: Any) -> None:
+ data = message.data
+ messages.append(data.get("msg", "") if isinstance(data, dict) else
str(data))
+
+ async with Client(mcp_server, log_handler=log_handler) as client:
+ await client.call_tool(
+ "get_dashboard_layout",
+ {"request": {"identifier": 1, "tab": "TAB-leaf-0"}},
+ )
+ retrieved = [m for m in messages if "Dashboard layout retrieved" in m]
+ assert len(retrieved) == 1
+ assert "tab_count=1," in retrieved[0]
+ assert "chart_count=4," in retrieved[0]
+ assert "'tab_id': 'TAB-leaf-0'" in retrieved[0]
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
[email protected]("tabs_only", [False, True])
[email protected]
+async def test_layout_empty_tab(
+ mock_find: Mock, mcp_server: FastMCP, tabs_only: bool
+) -> None:
+ """A valid empty tab remains discoverable and can be selected."""
+ position = json.loads(_tabbed_layout())
+ position["TAB-1"]["children"] = []
+ mock_find.return_value =
_build_dashboard_mock(position_json=json.dumps(position))
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_layout",
+ {"request": {"identifier": 1, "tab": "TAB-1", "tabs_only":
tabs_only}},
+ )
+ data = json.loads(result.content[0].text)
+ assert data["charts"] == []
+ if tabs_only:
+ assert len(data["tab_tree"]) == 1
+ assert data["tab_tree"][0]["chart_count"] == 0
+ else:
+ assert len(data["tabs"]) == 1
+ assert data["tabs"][0]["chart_ids"] == []