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 e041f253856 fix(mcp): return error when target_tab not found in 
add_chart_to_existing_dashboard (#40124)
e041f253856 is described below

commit e041f2538568ae147eade505a46f5c82d4a06cf1
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed May 27 11:29:43 2026 -0700

    fix(mcp): return error when target_tab not found in 
add_chart_to_existing_dashboard (#40124)
---
 superset/mcp_service/dashboard/schemas.py          |  25 ++-
 .../tool/add_chart_to_existing_dashboard.py        |  91 +++++++++--
 .../tool/test_add_chart_to_existing_dashboard.py   |  52 ++++++
 .../dashboard/tool/test_dashboard_generation.py    | 174 +++++++++++++++++++--
 4 files changed, 319 insertions(+), 23 deletions(-)

diff --git a/superset/mcp_service/dashboard/schemas.py 
b/superset/mcp_service/dashboard/schemas.py
index 9b889ff27ee..8904e9e0811 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -487,7 +487,17 @@ class AddChartToDashboardRequest(BaseModel):
     )
     chart_id: int = Field(..., description="ID of the chart to add to the 
dashboard")
     target_tab: str | None = Field(
-        None, description="Target tab name (if dashboard has tabs)"
+        None,
+        min_length=1,
+        description=(
+            "Tab to place the chart in. Accepts a tab display name "
+            "(e.g. 'Sales') or a tab component ID (e.g. 'TAB-abc123'). "
+            "Display-name matching is case-insensitive and strips all emoji; "
+            "component ID matching is case-sensitive and exact. "
+            "When not found, the error response lists all available tab names. 
"
+            "When omitted on a tabbed dashboard the chart is placed in the "
+            "first tab."
+        ),
     )
 
 
@@ -514,6 +524,19 @@ class AddChartToDashboardResponse(BaseModel):
         ),
     )
 
+    @field_validator("error")
+    @classmethod
+    def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
+        """Wrap error text before it is exposed to LLM context.
+
+        The error may echo user-supplied target_tab or dashboard-controlled tab
+        labels — both must be wrapped so the LLM treats them as data, not
+        instructions.
+        """
+        if value is None:
+            return value
+        return sanitize_for_llm_context(value, field_path=("error",))
+
 
 class GenerateDashboardRequest(BaseModel):
     """Request schema for generating a dashboard."""
diff --git 
a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py 
b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
index 72fcd482a55..4f64d53cbd0 100644
--- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
@@ -149,29 +149,50 @@ def _first_tab_from_groups(
     return None
 
 
+def _collect_available_tab_names(layout: Dict[str, Any]) -> list[str]:
+    """Collect display entries (label + component ID) for all TAB components.
+
+    Always includes the component ID so callers can retry unambiguously even
+    when multiple tabs share the same display name or a label is blank.
+    """
+    entries: list[str] = []
+    for tabs_children in _collect_tabs_groups(layout):
+        for tab_id in tabs_children:
+            tab = layout.get(tab_id)
+            if not tab or tab.get("type") != "TAB":
+                continue
+            text = (tab.get("meta") or {}).get("text", "")
+            entries.append(f"{text} ({tab_id})" if text else tab_id)
+    return entries
+
+
 def _find_tab_insert_target(
     layout: Dict[str, Any], target_tab: str | None = None
 ) -> str | None:
     """
     Detect if the dashboard uses tabs and return the appropriate tab's ID.
 
-    If *target_tab* is provided the function first tries to match it against
-    tab ``meta.text`` (display name) or the raw component ID.  When no match
-    is found (or *target_tab* is ``None``) the first ``TAB`` child is used as
-    a fallback so that new rows are still placed inside the tab structure
-    rather than directly under ``GRID_ID``.
+    When *target_tab* is ``None`` the function returns the first TAB child so
+    that new rows are placed inside the tab structure rather than directly
+    under ``GRID_ID``.
+
+    When *target_tab* is provided the function tries to match it against tab
+    ``meta.text`` (display name) or the raw component ID.  If no match is
+    found ``None`` is returned — the caller is responsible for surfacing an
+    error rather than silently placing the chart in the wrong tab.
 
     Returns:
-        The ID of the matched (or first) TAB component, or ``None`` if the
-        dashboard does not use top-level tabs.
+        The ID of the matched (or first) TAB component, or ``None``.
     """
     groups = _collect_tabs_groups(layout)
 
-    if target_tab:
+    if target_tab is not None:
         for tabs_children in groups:
             matched = _match_tab_in_children(layout, tabs_children, target_tab)
             if matched:
                 return matched
+        # target_tab specified but not found — signal mismatch to the caller.
+        return None
 
     return _first_tab_from_groups(layout, groups)
 
@@ -316,6 +337,45 @@ def _ensure_layout_structure(
         layout["DASHBOARD_VERSION_KEY"] = "v2"
 
 
+def _resolve_parent_container(
+    layout: Dict[str, Any],
+    dashboard_id: int,
+    target_tab: str | None,
+) -> tuple[str, None] | tuple[None, AddChartToDashboardResponse]:
+    """Return (parent_id, None) on success or (None, error_response) on 
mismatch.
+
+    When *target_tab* is specified and not found the caller receives a
+    descriptive error listing available tabs rather than a silent fallback.
+    """
+    tab_target = _find_tab_insert_target(layout, target_tab=target_tab)
+
+    if target_tab is not None and tab_target is None:
+        available = _collect_available_tab_names(layout)
+        if available:
+            tab_list = ", ".join(available)
+            return None, AddChartToDashboardResponse(
+                dashboard=None,
+                dashboard_url=None,
+                position=None,
+                error=(
+                    f"Tab '{target_tab}' not found in dashboard 
{dashboard_id}. "
+                    f"Available tabs: {tab_list}."
+                ),
+            )
+        return None, AddChartToDashboardResponse(
+            dashboard=None,
+            dashboard_url=None,
+            position=None,
+            error=(
+                f"Dashboard {dashboard_id} has no tabs. "
+                "Remove the target_tab parameter to add the chart to "
+                "the default grid layout."
+            ),
+        )
+
+    return (tab_target if tab_target else "GRID_ID", None)
+
+
 def _find_and_authorize_dashboard(
     dashboard_id: int,
 ) -> tuple[Any, AddChartToDashboardResponse | None]:
@@ -369,7 +429,7 @@ def _find_and_authorize_dashboard(
         destructiveHint=False,
     ),
 )
-def add_chart_to_existing_dashboard(
+def add_chart_to_existing_dashboard(  # noqa: C901 — complexity is structural 
(layout traversal + multi-step authorization), not accidental
     request: AddChartToDashboardRequest, ctx: Context
 ) -> AddChartToDashboardResponse:
     """
@@ -443,11 +503,16 @@ def add_chart_to_existing_dashboard(
             # Generate a unique ROW ID for the new row
             row_key = _find_next_row_position(current_layout)
 
-            # Detect tabbed dashboards and resolve target_tab by name or ID
-            tab_target = _find_tab_insert_target(
-                current_layout, target_tab=request.target_tab
+            # Detect tabbed dashboards and resolve target_tab by name or ID.
+            parent_id, tab_error = _resolve_parent_container(
+                current_layout, request.dashboard_id, request.target_tab
             )
-            parent_id = tab_target if tab_target else "GRID_ID"
+            if tab_error is not None:
+                return tab_error
+            if parent_id is None:
+                raise RuntimeError(
+                    "unreachable: tab_error is None implies parent_id is str"
+                )
 
             # Add chart, column, and row to layout
             chart_key, column_key, row_key = _add_chart_to_layout(
diff --git 
a/tests/unit_tests/mcp_service/dashboard/tool/test_add_chart_to_existing_dashboard.py
 
b/tests/unit_tests/mcp_service/dashboard/tool/test_add_chart_to_existing_dashboard.py
index de8058c92de..10cfa8045f1 100644
--- 
a/tests/unit_tests/mcp_service/dashboard/tool/test_add_chart_to_existing_dashboard.py
+++ 
b/tests/unit_tests/mcp_service/dashboard/tool/test_add_chart_to_existing_dashboard.py
@@ -295,3 +295,55 @@ async def test_successful_add(
     assert "/superset/dashboard/1/" in content["dashboard_url"]
     assert content["position"] is not None
     assert "chart_key" in content["position"]
+
+
+def test_empty_target_tab_rejected_by_schema() -> None:
+    """Empty string target_tab is rejected at schema layer, not as 'Tab not 
found'."""
+    from pydantic import ValidationError
+
+    from superset.mcp_service.dashboard.schemas import 
AddChartToDashboardRequest
+
+    with pytest.raises(ValidationError):
+        AddChartToDashboardRequest(dashboard_id=1, chart_id=10, target_tab="")
+
+    # None is valid (tab omitted)
+    req = AddChartToDashboardRequest(dashboard_id=1, chart_id=10, 
target_tab=None)
+    assert req.target_tab is None
+
+
+def test_add_chart_response_error_is_sanitized_for_llm_context() -> None:
+    """Error field wraps user-supplied target_tab and dashboard tab labels.
+
+    The error string echoes user-provided input (target_tab) and
+    dashboard-controlled tab labels.  Both must be wrapped in
+    UNTRUSTED-CONTENT delimiters so the LLM treats them as data, not
+    instructions.
+    """
+    from superset.mcp_service.dashboard.schemas import 
AddChartToDashboardResponse
+    from superset.mcp_service.utils.sanitization import (
+        LLM_CONTEXT_CLOSE_DELIMITER,
+        LLM_CONTEXT_OPEN_DELIMITER,
+    )
+
+    raw_error = (
+        "Tab 'malicious tab <script>alert(1)</script>' not found in dashboard 
42. "
+        "Available tabs: Sales (TAB-abc), <b>Marketing</b> (TAB-xyz)."
+    )
+    response = AddChartToDashboardResponse(
+        dashboard=None,
+        dashboard_url=None,
+        position=None,
+        error=raw_error,
+    )
+
+    assert response.error is not None
+    assert LLM_CONTEXT_OPEN_DELIMITER in response.error
+    assert LLM_CONTEXT_CLOSE_DELIMITER in response.error
+    # Core text is still present inside the wrapper
+    assert "not found" in response.error
+    assert "Available tabs" in response.error
+    # None error is passed through unchanged
+    empty_response = AddChartToDashboardResponse(
+        dashboard=None, dashboard_url=None, position=None, error=None
+    )
+    assert empty_response.error is None
diff --git 
a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py 
b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
index b18d6a125bf..c7a07b724c7 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
@@ -32,6 +32,7 @@ from superset.mcp_service.chart.chart_utils import 
DatasetValidationResult
 from superset.mcp_service.dashboard.constants import generate_id
 from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard 
import (
     _add_chart_to_layout,
+    _collect_available_tab_names,
     _ensure_layout_structure,
     _find_next_row_position,
     _find_tab_insert_target,
@@ -1059,6 +1060,112 @@ class TestAddChartToExistingDashboard:
             assert "TAB-tab2" in chart_parents
             assert "TAB-tab1" not in chart_parents
 
+    @patch("superset.commands.dashboard.update.UpdateDashboardCommand")
+    @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+    @patch("superset.db.session")
+    @pytest.mark.asyncio
+    async def test_add_chart_target_tab_not_found(
+        self, mock_db_session, mock_find_dashboard, mock_update_command, 
mcp_server
+    ) -> None:
+        """target_tab specified but no matching tab → descriptive error listing
+        available tabs, not a silent fallback to the first tab."""
+        mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard")
+        mock_dashboard.slices = [_mock_chart(id=10)]
+        mock_dashboard.position_json = json.dumps(
+            {
+                "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": 
"ROOT"},
+                "GRID_ID": {
+                    "children": ["TABS-abc123"],
+                    "id": "GRID_ID",
+                    "parents": ["ROOT_ID"],
+                    "type": "GRID",
+                },
+                "TABS-abc123": {
+                    "children": ["TAB-tab1", "TAB-tab2"],
+                    "id": "TABS-abc123",
+                    "parents": ["ROOT_ID", "GRID_ID"],
+                    "type": "TABS",
+                },
+                "TAB-tab1": {
+                    "children": [],
+                    "id": "TAB-tab1",
+                    "meta": {"text": "Overview"},
+                    "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
+                    "type": "TAB",
+                },
+                "TAB-tab2": {
+                    "children": [],
+                    "id": "TAB-tab2",
+                    "meta": {"text": "Details"},
+                    "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
+                    "type": "TAB",
+                },
+                "DASHBOARD_VERSION_KEY": "v2",
+            }
+        )
+        mock_chart = _mock_chart(id=30)
+        mock_db_session.get.return_value = mock_chart
+        mock_find_dashboard.return_value = mock_dashboard
+
+        request = {"dashboard_id": 3, "chart_id": 30, "target_tab": 
"Nonexistent Tab"}
+
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "add_chart_to_existing_dashboard", {"request": request}
+            )
+
+            assert result.structured_content["error"] is not None
+            error = result.structured_content["error"]
+            assert "Nonexistent Tab" in error
+            assert "not found" in error
+            # Available tabs listed with both label and component ID
+            assert "Overview" in error
+            assert "Details" in error
+            assert "TAB-tab1" in error
+            assert "TAB-tab2" in error
+            # No layout mutation should have been persisted
+            mock_update_command.assert_not_called()
+
+    @patch("superset.commands.dashboard.update.UpdateDashboardCommand")
+    @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+    @patch("superset.db.session")
+    @pytest.mark.asyncio
+    async def test_add_chart_target_tab_on_non_tabbed_dashboard(
+        self, mock_db_session, mock_find_dashboard, mock_update_command, 
mcp_server
+    ) -> None:
+        """target_tab on a dashboard with no tabs → descriptive error."""
+        mock_dashboard = _mock_dashboard(id=5, title="Flat Dashboard")
+        mock_dashboard.slices = []
+        mock_dashboard.position_json = json.dumps(
+            {
+                "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": 
"ROOT"},
+                "GRID_ID": {
+                    "children": [],
+                    "id": "GRID_ID",
+                    "parents": ["ROOT_ID"],
+                    "type": "GRID",
+                },
+                "DASHBOARD_VERSION_KEY": "v2",
+            }
+        )
+        mock_chart = _mock_chart(id=99)
+        mock_db_session.get.return_value = mock_chart
+        mock_find_dashboard.return_value = mock_dashboard
+
+        request = {"dashboard_id": 5, "chart_id": 99, "target_tab": "Sales"}
+
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "add_chart_to_existing_dashboard", {"request": request}
+            )
+
+            assert result.structured_content["error"] is not None
+            error = result.structured_content["error"]
+            assert "no tabs" in error.lower()
+            assert "target_tab" in error
+            # No layout mutation should have been persisted
+            mock_update_command.assert_not_called()
+
     @patch("superset.commands.dashboard.update.UpdateDashboardCommand")
     @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
     @patch("superset.db.session")
@@ -1311,9 +1418,9 @@ class TestLayoutHelpers:
         }
         assert _find_tab_insert_target(layout, target_tab="TAB-second") == 
"TAB-second"
 
-    def test_find_tab_insert_target_unmatched_falls_back_to_first(self):
-        """Test _find_tab_insert_target falls back to first tab when target_tab
-        doesn't match any tab name or ID."""
+    def test_find_tab_insert_target_unmatched_returns_none(self):
+        """Test _find_tab_insert_target returns None when target_tab doesn't
+        match any tab name or ID, so the caller can return a descriptive 
error."""
         layout = {
             "GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
             "TABS-main": {"children": ["TAB-first", "TAB-second"], "type": 
"TABS"},
@@ -1328,11 +1435,19 @@ class TestLayoutHelpers:
                 "meta": {"text": "Tab 2"},
             },
         }
-        assert (
-            _find_tab_insert_target(layout, target_tab="Nonexistent Tab") == 
"TAB-first"
-        )
+        assert _find_tab_insert_target(layout, target_tab="Nonexistent Tab") 
is None
+
+    def test_find_tab_insert_target_empty_string_returns_none(self) -> None:
+        """An empty-string target_tab is treated as specified-but-not-found,
+        not as 'no tab requested', so it returns None rather than first tab."""
+        layout = {
+            "GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
+            "TABS-main": {"children": ["TAB-first"], "type": "TABS"},
+            "TAB-first": {"children": [], "type": "TAB", "meta": {"text": "Tab 
1"}},
+        }
+        assert _find_tab_insert_target(layout, target_tab="") is None
 
-    def test_find_tab_insert_target_tabs_under_root(self):
+    def test_find_tab_insert_target_tabs_under_root(self) -> None:
         """Test _find_tab_insert_target when TABS are under ROOT_ID (real 
layout)."""
         layout = {
             "ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"},
@@ -1343,7 +1458,7 @@ class TestLayoutHelpers:
         }
         assert _find_tab_insert_target(layout) == "TAB-a"
 
-    def test_find_tab_insert_target_tabs_under_root_by_name(self):
+    def test_find_tab_insert_target_tabs_under_root_by_name(self) -> None:
         """Test _find_tab_insert_target matches tab name when TABS under 
ROOT_ID."""
         layout = {
             "ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"},
@@ -1354,10 +1469,51 @@ class TestLayoutHelpers:
         }
         assert _find_tab_insert_target(layout, target_tab="Details") == "TAB-b"
 
-    def test_find_tab_insert_target_no_grid(self):
+    def test_find_tab_insert_target_no_grid(self) -> None:
         """Test _find_tab_insert_target with missing GRID_ID."""
         assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
 
+    def test_collect_available_tab_names_returns_display_names(self) -> None:
+        """_collect_available_tab_names returns label + component ID for each 
tab."""
+        layout = {
+            "GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
+            "TABS-x": {"children": ["TAB-a", "TAB-b"], "type": "TABS"},
+            "TAB-a": {"children": [], "type": "TAB", "meta": {"text": 
"Overview"}},
+            "TAB-b": {"children": [], "type": "TAB", "meta": {"text": 
"Details"}},
+        }
+        names = _collect_available_tab_names(layout)
+        assert names == ["Overview (TAB-a)", "Details (TAB-b)"]
+
+    def test_collect_available_tab_names_falls_back_to_id(self) -> None:
+        """_collect_available_tab_names uses component ID only when text is 
empty."""
+        layout = {
+            "GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
+            "TABS-x": {"children": ["TAB-a"], "type": "TABS"},
+            "TAB-a": {"children": [], "type": "TAB", "meta": {}},
+        }
+        names = _collect_available_tab_names(layout)
+        assert names == ["TAB-a"]
+
+    def test_collect_available_tab_names_duplicate_names(self) -> None:
+        """Duplicate display names are disambiguated by component ID in the 
entry."""
+        layout = {
+            "GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
+            "TABS-x": {"children": ["TAB-a", "TAB-b"], "type": "TABS"},
+            "TAB-a": {"children": [], "type": "TAB", "meta": {"text": 
"Sales"}},
+            "TAB-b": {"children": [], "type": "TAB", "meta": {"text": 
"Sales"}},
+        }
+        names = _collect_available_tab_names(layout)
+        assert names == ["Sales (TAB-a)", "Sales (TAB-b)"]
+        assert names[0] != names[1]
+
+    def test_collect_available_tab_names_no_tabs(self) -> None:
+        """_collect_available_tab_names returns empty list for non-tabbed 
dashboards."""
+        layout = {
+            "GRID_ID": {"children": ["ROW-1"], "type": "GRID"},
+            "ROW-1": {"children": [], "type": "ROW"},
+        }
+        assert _collect_available_tab_names(layout) == []
+
     def test_add_chart_to_layout_creates_column(self):
         """Test that _add_chart_to_layout creates ROW > COLUMN > CHART."""
         layout = {

Reply via email to