diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 73a57a6e292e..4e198c353569 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -301,6 +301,7 @@ def validate_search_and_filters(self) -> "ListDashboardsRequest": "created_on", "changed_on", "uuid", + "embedded_uuid", "url", "created_on_humanized", "changed_on_humanized", @@ -425,6 +426,18 @@ class DashboardInfo(BaseModel): created_on: str | datetime | None = None changed_on: str | datetime | None = None uuid: str | None = None + embedded_uuid: str | None = Field( + None, + description=( + "Embedded UUID for this dashboard. This is the UUID required when " + "generating guest tokens for embedded dashboards " + "(resources[].id in the guest token payload). " + "Only present when the dashboard has been configured for embedding " + "via the Embed Dashboard UI. Distinct from `uuid` (the internal " + "dashboard UUID) — using the wrong one causes 403 errors in guest " + "token validation." + ), + ) url: str | None = None created_on_humanized: str | None = None changed_on_humanized: str | None = None @@ -1137,6 +1150,9 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo: created_on=dashboard.created_on, changed_on=dashboard.changed_on, uuid=str(dashboard.uuid) if dashboard.uuid else None, + embedded_uuid=str(dashboard.embedded[0].uuid) + if dashboard.embedded + else None, url=absolute_url, created_on_humanized=dashboard.created_on_humanized, changed_on_humanized=dashboard.changed_on_humanized, diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py b/superset/mcp_service/dashboard/tool/get_dashboard_info.py index f7f39e27228b..9a414dae06c3 100644 --- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py +++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py @@ -155,10 +155,11 @@ async def get_dashboard_info( from superset.models.dashboard import Dashboard from superset.models.slice import Slice - # Eager load slices and tags to avoid N+1 queries during serialization. + # Eager load slices, tags, and embedded to avoid N+1 queries. eager_options = [ subqueryload(Dashboard.slices).subqueryload(Slice.tags), subqueryload(Dashboard.tags), + subqueryload(Dashboard.embedded), ] with event_logger.log_context(action="mcp.get_dashboard_info.lookup"): diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py index 3cabe42f471e..b6611b0c2c16 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py @@ -96,6 +96,7 @@ async def test_list_dashboards_basic(mock_list, mcp_server): dashboard.uuid = "test-dashboard-uuid-1" dashboard.thumbnail_url = None dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] dashboard._mapping = { "id": dashboard.id, @@ -162,6 +163,7 @@ async def test_list_dashboards_with_filters(mock_list, mcp_server): dashboard.uuid = None dashboard.thumbnail_url = None dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] dashboard._mapping = { "id": dashboard.id, @@ -257,6 +259,7 @@ async def test_list_dashboards_with_search(mock_list, mcp_server): dashboard.uuid = None dashboard.thumbnail_url = None dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] dashboard._mapping = { "id": dashboard.id, @@ -351,6 +354,7 @@ async def test_get_dashboard_info_success( dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] dashboard._mapping = { "id": dashboard.id, @@ -429,6 +433,7 @@ async def test_get_dashboard_info_permalink_does_not_double_sanitize( dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] mock_info.return_value = dashboard permalink_value = { @@ -521,6 +526,7 @@ async def test_get_dashboard_info_permalink_key_includes_filter_state( dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] mock_info.return_value = dashboard @@ -767,6 +773,7 @@ async def test_get_dashboard_info_does_not_expose_access_list_or_roles( dashboard.owners = [owner] dashboard.tags = [] dashboard.roles = [dashboard_role] + dashboard.embedded = [] mock_info.return_value = dashboard @@ -838,6 +845,7 @@ async def test_get_dashboard_info_restricted_user_redacts_data_model_metadata( dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] mock_info.return_value = dashboard @@ -890,6 +898,7 @@ async def test_get_dashboard_info_restricted_user_redacts_permalink_filter_state dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] mock_info.return_value = dashboard @@ -1012,6 +1021,88 @@ async def test_list_dashboards_omits_requested_user_directory_fields( assert field not in data["columns_available"] +@patch("superset.mcp_service.mcp_core.ModelGetInfoCore._find_object") +@pytest.mark.asyncio +async def test_get_dashboard_info_includes_embedded_uuid(mock_find_object, mcp_server): + """Test that get_dashboard_info returns embedded_uuid when set.""" + from superset.models.embedded_dashboard import EmbeddedDashboard + + dashboard = Mock() + dashboard.id = 1 + dashboard.dashboard_title = "Embedded Dashboard" + dashboard.slug = "" + dashboard.description = None + dashboard.css = None + dashboard.certified_by = None + dashboard.certification_details = None + dashboard.json_metadata = "{}" + dashboard.published = True + dashboard.is_managed_externally = False + dashboard.external_url = None + dashboard.created_on = None + dashboard.changed_on = None + dashboard.created_on_humanized = None + dashboard.changed_on_humanized = None + dashboard.uuid = "94b826a5-dbd5-473d-ab58-1af676ee07e4" + dashboard.url = "/dashboard/1" + dashboard.slices = [] + dashboard.owners = [] + dashboard.tags = [] + dashboard.roles = [] + dashboard.embedded = [] + + embedded = Mock(spec=EmbeddedDashboard) + embedded.uuid = "37c56048-d3f1-452d-b3ae-0879802dcb1f" + dashboard.embedded = [embedded] + + mock_find_object.return_value = dashboard + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", {"request": {"identifier": 1}} + ) + assert result.data["uuid"] == "94b826a5-dbd5-473d-ab58-1af676ee07e4" + assert result.data["embedded_uuid"] == "37c56048-d3f1-452d-b3ae-0879802dcb1f" + + +@patch("superset.mcp_service.mcp_core.ModelGetInfoCore._find_object") +@pytest.mark.asyncio +async def test_get_dashboard_info_embedded_uuid_none_when_not_embedded( + mock_find_object, mcp_server +): + """Test that embedded_uuid is None when the dashboard has not been configured + for embedding.""" + dashboard = Mock() + dashboard.id = 2 + dashboard.dashboard_title = "Non-Embedded Dashboard" + dashboard.slug = "" + dashboard.description = None + dashboard.css = None + dashboard.certified_by = None + dashboard.certification_details = None + dashboard.json_metadata = "{}" + dashboard.published = True + dashboard.is_managed_externally = False + dashboard.external_url = None + dashboard.created_on = None + dashboard.changed_on = None + dashboard.created_on_humanized = None + dashboard.changed_on_humanized = None + dashboard.uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + dashboard.url = "/dashboard/2" + dashboard.slices = [] + dashboard.owners = [] + dashboard.tags = [] + dashboard.roles = [] + dashboard.embedded = [] + + mock_find_object.return_value = dashboard + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", {"request": {"identifier": 2}} + ) + assert result.data.get("embedded_uuid") is None + + # TODO (Phase 3+): Add tests for get_dashboard_available_filters tool @@ -1044,6 +1135,7 @@ async def test_get_dashboard_info_by_uuid(mock_find_object, mcp_server): dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] mock_find_object.return_value = dashboard async with Client(mcp_server) as client: @@ -1083,6 +1175,7 @@ async def test_get_dashboard_info_by_slug(mock_find_object, mcp_server): dashboard.owners = [] dashboard.tags = [] dashboard.roles = [] + dashboard.embedded = [] mock_find_object.return_value = dashboard async with Client(mcp_server) as client: @@ -1122,6 +1215,7 @@ async def test_list_dashboards_custom_uuid_slug_columns(mock_list, mcp_server): dashboard.external_url = None dashboard.thumbnail_url = None dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] dashboard._mapping = { "id": dashboard.id, @@ -1203,6 +1297,7 @@ async def test_list_dashboards_sanitizes_dashboard_descriptions_and_filter_text( dashboard.external_url = None dashboard.thumbnail_url = None dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] dashboard._mapping = { "id": dashboard.id, @@ -1343,6 +1438,7 @@ async def test_default_columns_in_response(self, mock_list, mcp_server): dashboard.external_url = None dashboard.thumbnail_url = None dashboard.roles = [] + dashboard.embedded = [] dashboard.charts = [] mock_list.return_value = ([dashboard], 1)