You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/16 06:36:11 UTC
(superset) branch master updated: feat(mcp): deleted_state trash listing for list_charts and list_dashboards (#41855)
This is an automated email from the ASF dual-hosted git repository.
rusackas 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 2f7afe4b47d feat(mcp): deleted_state trash listing for list_charts and list_dashboards (#41855)
2f7afe4b47d is described below
commit 2f7afe4b47dd6373cd7c4c4afdd9fe7da1d68dd9
Author: Greg Neighbors <gk...@mac.com>
AuthorDate: Thu Jul 16 02:36:04 2026 -0400
feat(mcp): deleted_state trash listing for list_charts and list_dashboards (#41855)
Co-authored-by: Greg Neighbors <gr...@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <no...@anthropic.com>
---
superset/mcp_service/app.py | 4 +-
superset/mcp_service/chart/schemas.py | 22 +++
superset/mcp_service/chart/tool/list_charts.py | 3 +
superset/mcp_service/dashboard/schemas.py | 22 +++
.../mcp_service/dashboard/tool/list_dashboards.py | 3 +
superset/mcp_service/mcp_core.py | 96 +++++++++--
.../mcp_service/chart/tool/test_generate_chart.py | 1 +
.../chart/tool/test_list_charts_deleted_state.py | 171 ++++++++++++++++++++
.../dashboard/tool/test_dashboard_tools.py | 15 ++
.../tool/test_list_dashboards_deleted_state.py | 176 +++++++++++++++++++++
tests/unit_tests/mcp_service/test_mcp_core.py | 73 ++++++++-
11 files changed, 574 insertions(+), 12 deletions(-)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index d6103a14e3a..f6954c5d71a 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -125,7 +125,7 @@ necessary access — do NOT attempt to call it.
Available tools:
Dashboard Management:
-- list_dashboards: List dashboards with advanced filters (1-based pagination)
+- list_dashboards: List dashboards with advanced filters (1-based pagination; deleted_state='only'/'include' surfaces trashed dashboards the caller may restore)
- 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)
- get_dashboard_datasets: List the datasets used by a dashboard's charts, with columns and metrics (context for configuring native filters)
@@ -180,7 +180,7 @@ Dataset Management:
- query_dataset: Query a dataset using its semantic layer (saved metrics, dimensions, filters) without needing a saved chart
Chart Management:
-- list_charts: List charts with advanced filters (1-based pagination)
+- list_charts: List charts with advanced filters (1-based pagination; deleted_state='only'/'include' surfaces trashed charts the caller may restore)
- get_chart_info: Get detailed chart information by ID
- get_chart_preview: Get a visual preview of a chart as formatted content or URL
- get_chart_data: Get underlying chart data in text-friendly format
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index f303956b581..688fbed5042 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -142,6 +142,13 @@ class ChartInfo(BaseModel):
None, description="Certification details or reason"
)
uuid: str | None = Field(None, description="Chart UUID")
+ deleted_at: str | datetime | None = Field(
+ None,
+ description=(
+ "When the chart was moved to trash (soft-deleted); null for live "
+ "charts. Only populated when listing with deleted_state."
+ ),
+ )
tags: List[TagInfo] = Field(default_factory=list, description="Chart tags")
editors: List[SubjectInfo] = Field(
default_factory=list, description="Chart editors"
@@ -626,6 +633,7 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
uuid=str(getattr(chart, "uuid", ""))
if getattr(chart, "uuid", None)
else None,
+ deleted_at=getattr(chart, "deleted_at", None),
tags=[
TagInfo.model_validate(tag, from_attributes=True)
for tag in getattr(chart, "tags", [])
@@ -2161,6 +2169,20 @@ class ListChartsRequest(EditedByMeMixin, CreatedByMeMixin, MetadataCacheControl)
"used together with 'filters'.",
),
]
+ deleted_state: Annotated[
+ Literal["include", "only"] | None,
+ Field(
+ default=None,
+ description=(
+ "Surface soft-deleted (trashed) charts: 'only' returns just "
+ "trashed charts, 'include' returns live and trashed charts "
+ "together. Omit for live charts only (default). Trashed rows "
+ "carry a non-null deleted_at and are limited to charts the "
+ "caller owns (admins see all); requires the SOFT_DELETE "
+ "feature flag to have produced trashed rows."
+ ),
+ ),
+ ]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
diff --git a/superset/mcp_service/chart/tool/list_charts.py b/superset/mcp_service/chart/tool/list_charts.py
index acd23801408..4f7aea735e7 100644
--- a/superset/mcp_service/chart/tool/list_charts.py
+++ b/superset/mcp_service/chart/tool/list_charts.py
@@ -133,6 +133,7 @@ async def list_charts(
)
)
+ from superset.charts.filters import ChartDeletedStateFilter
from superset.daos.chart import ChartDAO
from superset.mcp_service.common.schema_discovery import (
CHART_SORTABLE_COLUMNS,
@@ -182,6 +183,7 @@ async def list_charts(
all_columns=all_columns,
sortable_columns=sortable_columns,
logger=logger,
+ deleted_state_filter=ChartDeletedStateFilter,
)
try:
@@ -196,6 +198,7 @@ async def list_charts(
page_size=request.page_size,
created_by_me=request.created_by_me,
edited_by_me=request.edited_by_me,
+ deleted_state=request.deleted_state,
)
count = len(result.charts) if hasattr(result, "charts") else 0
total_pages = getattr(result, "total_pages", None)
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index 8c662df5ee9..0c454b299cf 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -253,6 +253,20 @@ class ListDashboardsRequest(EditedByMeMixin, CreatedByMeMixin, MetadataCacheCont
"Cannot be used together with 'filters'.",
),
]
+ deleted_state: Annotated[
+ Literal["include", "only"] | None,
+ Field(
+ default=None,
+ description=(
+ "Surface soft-deleted (trashed) dashboards: 'only' returns "
+ "just trashed dashboards, 'include' returns live and trashed "
+ "together. Omit for live dashboards only (default). Trashed "
+ "rows carry a non-null deleted_at and are limited to "
+ "dashboards the caller owns (admins see all); requires the "
+ "SOFT_DELETE feature flag to have produced trashed rows."
+ ),
+ ),
+ ]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
@@ -443,6 +457,13 @@ class DashboardInfo(BaseModel):
created_on: str | datetime | None = None
changed_on: str | datetime | None = None
uuid: str | None = None
+ deleted_at: str | datetime | None = Field(
+ None,
+ description=(
+ "When the dashboard was moved to trash (soft-deleted); null for "
+ "live dashboards. Only populated when listing with deleted_state."
+ ),
+ )
embedded_uuid: str | None = Field(
None,
description=(
@@ -1730,6 +1751,7 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
css=getattr(dashboard, "css", None),
certified_by=getattr(dashboard, "certified_by", None),
certification_details=getattr(dashboard, "certification_details", None),
+ deleted_at=getattr(dashboard, "deleted_at", None),
native_filters=_extract_native_filters(
json_metadata_str,
include_data_model_metadata=include_data_model_metadata,
diff --git a/superset/mcp_service/dashboard/tool/list_dashboards.py b/superset/mcp_service/dashboard/tool/list_dashboards.py
index 68906f72557..eb279c932a5 100644
--- a/superset/mcp_service/dashboard/tool/list_dashboards.py
+++ b/superset/mcp_service/dashboard/tool/list_dashboards.py
@@ -130,6 +130,7 @@ async def list_dashboards(
)
from superset.daos.dashboard import DashboardDAO
+ from superset.dashboards.filters import DashboardDeletedStateFilter
from superset.mcp_service.common.schema_discovery import (
DASHBOARD_SORTABLE_COLUMNS,
get_all_column_names,
@@ -161,6 +162,7 @@ async def list_dashboards(
all_columns=all_columns,
sortable_columns=DASHBOARD_SORTABLE_COLUMNS,
logger=logger,
+ deleted_state_filter=DashboardDeletedStateFilter,
)
with event_logger.log_context(action="mcp.list_dashboards.query"):
@@ -174,6 +176,7 @@ async def list_dashboards(
page_size=request.page_size,
created_by_me=request.created_by_me,
edited_by_me=request.edited_by_me,
+ deleted_state=request.deleted_state,
)
count = len(result.dashboards) if hasattr(result, "dashboards") else 0
total_pages = getattr(result, "total_pages", None)
diff --git a/superset/mcp_service/mcp_core.py b/superset/mcp_service/mcp_core.py
index 5b185aa6672..6e1bd5fd21e 100644
--- a/superset/mcp_service/mcp_core.py
+++ b/superset/mcp_service/mcp_core.py
@@ -43,6 +43,7 @@ from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_passthrough,
)
+from superset.models.helpers import skip_visibility_filter
from superset.utils import json
@@ -114,6 +115,28 @@ class BaseCore(ABC):
self.logger.warning(message)
+class DeletedStateBoundFilter:
+ """Adapt a FAB deleted-state filter for ``BaseDAO.list`` custom_filters.
+
+ ``BaseDAO.list`` invokes custom filters as ``apply(query, None)``, but the
+ ``BaseDeletedStateFilter`` subclasses interpret ``None`` as "live rows
+ only". Binding the value at construction lets the DAO-side invocation
+ reach the FAB filter with the caller's actual ``include``/``only`` choice.
+
+ ``model`` re-exposes the FAB filter's SoftDeleteMixin model class so the
+ caller can scope the session visibility bypass without re-consulting the
+ (Optional) filter-class attribute.
+ """
+
+ def __init__(self, inner: Any, value: str, model: type) -> None:
+ self._inner = inner
+ self._value = value
+ self.model = model
+
+ def apply(self, query: Any, value: Any) -> Any:
+ return self._inner.apply(query, self._value)
+
+
class ModelListCore(BaseCore, Generic[L]):
"""
Generic tool for listing model objects with filtering, search, pagination, and
@@ -150,6 +173,7 @@ class ModelListCore(BaseCore, Generic[L]):
all_columns: List[str] | None = None,
sortable_columns: List[str] | None = None,
editor_filter_column: str = "editor",
+ deleted_state_filter: type | None = None,
) -> None:
super().__init__(logger)
self.dao_class = dao_class
@@ -170,6 +194,12 @@ class ModelListCore(BaseCore, Generic[L]):
sortable_columns if sortable_columns else []
)
self._editor_filter_column = editor_filter_column
+ # A BaseDeletedStateFilter subclass (e.g. ChartDeletedStateFilter).
+ # The FAB filter owns the restore-audience scoping — only owners and
+ # admins may enumerate soft-deleted rows — so reusing it here keeps
+ # that cross-entity contract in one place. None means the resource
+ # does not support deleted_state listing.
+ self._deleted_state_filter = deleted_state_filter
@property
def all_columns(self) -> List[str]:
@@ -270,6 +300,7 @@ class ModelListCore(BaseCore, Generic[L]):
page_size: int,
search: str | None,
columns_to_load: List[str],
+ custom_filters: Dict[str, Any] | None = None,
) -> tuple[List[Any], int]:
"""Call the DAO list method.
@@ -284,8 +315,31 @@ class ModelListCore(BaseCore, Generic[L]):
search=search,
search_columns=self.search_columns,
columns=columns_to_load,
+ custom_filters=custom_filters,
)
+ def _build_deleted_state_filter(
+ self, deleted_state: str | None
+ ) -> DeletedStateBoundFilter | None:
+ """Validate deleted_state and bind it to the entity's FAB filter.
+
+ Returns None when trash listing was not requested. Raises for a
+ value other than ``include``/``only`` or when the resource has no
+ deleted-state filter configured.
+ """
+ if deleted_state is None:
+ return None
+ normalized = str(deleted_state).lower().strip()
+ if normalized not in {"include", "only"}:
+ raise ValueError("deleted_state must be 'include' or 'only'")
+ if self._deleted_state_filter is None:
+ raise ValueError("deleted_state is not supported for this resource")
+ # ``model`` is the ClassVar every BaseDeletedStateFilter subclass binds.
+ model = self._deleted_state_filter.model # type: ignore[attr-defined]
+ datamodel = SQLAInterface(model, db.session)
+ inner = self._deleted_state_filter("id", datamodel)
+ return DeletedStateBoundFilter(inner, normalized, model)
+
def run_tool(
self,
filters: Any | None = None,
@@ -297,6 +351,7 @@ class ModelListCore(BaseCore, Generic[L]):
page_size: int = 10,
created_by_me: bool = False,
edited_by_me: bool = False,
+ deleted_state: str | None = None,
) -> L:
# Clamp page_size to MAX_PAGE_SIZE as defense-in-depth
page_size = min(page_size, MAX_PAGE_SIZE)
@@ -326,17 +381,40 @@ class ModelListCore(BaseCore, Generic[L]):
self._validate_order_column(order_column)
+ deleted_state_bound = self._build_deleted_state_filter(deleted_state)
+ if deleted_state_bound is not None:
+ # Trashed rows must be distinguishable from live ones (matters in
+ # "include" mode), so force deleted_at into the loaded columns
+ # and the serialization allowlist.
+ for column_list in (columns_requested, columns_to_load):
+ if "deleted_at" not in column_list:
+ column_list.append("deleted_at")
+
# Query the DAO
items: List[Any]
- items, total_count = self._call_dao_list(
- filters=filters,
- order_column=order_column or "changed_on",
- order_direction=str(order_direction or "desc"),
- page=page,
- page_size=page_size,
- search=search,
- columns_to_load=columns_to_load,
- )
+ dao_kwargs = {
+ "filters": filters,
+ "order_column": order_column or "changed_on",
+ "order_direction": str(order_direction or "desc"),
+ "page": page,
+ "page_size": page_size,
+ "search": search,
+ "columns_to_load": columns_to_load,
+ }
+ if deleted_state_bound is not None:
+ # The soft-delete ORM listener appends ``deleted_at IS NULL`` at
+ # execution time, so the session-scoped bypass must span both
+ # executions inside DAO.list (count + fetch). The context manager
+ # guarantees release even on exceptions; the FAB filter's
+ # restore-audience scoping (applied via custom_filters) decides
+ # which unhidden rows the caller may actually see.
+ with skip_visibility_filter(db.session, deleted_state_bound.model):
+ items, total_count = self._call_dao_list(
+ custom_filters={"deleted_state": deleted_state_bound},
+ **dao_kwargs,
+ )
+ else:
+ items, total_count = self._call_dao_list(**dao_kwargs)
# Serialize items
item_objs = []
for item in items:
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
index dc758e3b3b0..52e59d62597 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
@@ -432,6 +432,7 @@ def _make_mock_chart(chart_id: int = 42) -> Mock:
chart.created_on = None
chart.created_on_humanized = "2 days ago"
chart.uuid = "test-uuid-42"
+ chart.deleted_at = None
chart.tags = []
chart.editors = []
return chart
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_list_charts_deleted_state.py b/tests/unit_tests/mcp_service/chart/tool/test_list_charts_deleted_state.py
new file mode 100644
index 00000000000..cd5306f1613
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/tool/test_list_charts_deleted_state.py
@@ -0,0 +1,171 @@
+# 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 list_charts ``deleted_state`` trash listing.
+
+The deleted_state param opts the list query into surfacing soft-deleted
+charts: the session-scoped visibility bypass wraps the DAO call, the REST
+``ChartDeletedStateFilter`` (which owns the restore-audience scoping) is
+passed through as a DAO custom filter, and ``deleted_at`` is forced into the
+loaded columns so trashed rows are distinguishable in the response.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from unittest.mock import MagicMock, Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+_DAO_LIST = "superset.daos.chart.ChartDAO.list"
+_BYPASS = "superset.mcp_service.mcp_core.skip_visibility_filter"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ 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 _trashed_chart_row() -> Mock:
+ row = Mock(
+ spec=[
+ "id",
+ "slice_name",
+ "viz_type",
+ "changed_on",
+ "created_on",
+ "uuid",
+ "deleted_at",
+ ]
+ )
+ row.id = 10
+ row.slice_name = "Trashed Sales"
+ row.viz_type = "table"
+ row.changed_on = datetime(2026, 6, 1)
+ row.created_on = datetime(2026, 5, 1)
+ row.uuid = UUID("11111111-2222-3333-4444-555555555555")
+ row.deleted_at = datetime(2026, 7, 1)
+ return row
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_charts_deleted_state_only_passes_custom_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_charts", {"request": {"deleted_state": "only"}})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" in (kwargs.get("custom_filters") or {})
+ assert "deleted_at" in kwargs["columns"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_charts_default_has_no_deleted_state_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_charts", {})
+
+ kwargs = mock_list.call_args.kwargs
+ assert not kwargs.get("custom_filters")
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_charts_deleted_state_wraps_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ from superset.models.slice import Slice
+
+ mock_list.return_value = ([], 0)
+ mock_bypass.return_value.__enter__ = Mock(return_value=None)
+ mock_bypass.return_value.__exit__ = Mock(return_value=False)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_charts", {"request": {"deleted_state": "include"}})
+
+ mock_bypass.assert_called_once()
+ assert Slice in mock_bypass.call_args.args
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_charts_no_deleted_state_no_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_charts", {})
+
+ mock_bypass.assert_not_called()
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_charts_deleted_state_serializes_deleted_at(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([_trashed_chart_row()], 1)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_charts", {"request": {"deleted_state": "only"}}
+ )
+
+ data = json.loads(result.content[0].text)
+ assert data["charts"][0]["id"] == 10
+ assert data["charts"][0]["deleted_at"] is not None
+ assert "deleted_at" in data["columns_loaded"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_charts_deleted_state_invalid_value_rejected(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ with pytest.raises(ToolError):
+ await client.call_tool(
+ "list_charts", {"request": {"deleted_state": "everything"}}
+ )
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 3c64b403172..34a67c1888a 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
@@ -90,6 +90,7 @@ async def test_list_dashboards_basic(mock_list, mcp_server):
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.is_managed_externally = False
dashboard.external_url = None
@@ -156,6 +157,7 @@ async def test_list_dashboards_with_filters(mock_list, mcp_server):
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.is_managed_externally = False
dashboard.external_url = None
@@ -251,6 +253,7 @@ async def test_list_dashboards_with_search(mock_list, mcp_server):
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.is_managed_externally = False
dashboard.external_url = None
@@ -518,6 +521,7 @@ async def test_get_dashboard_info_permalink_key_includes_filter_state(
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.published = True
dashboard.is_managed_externally = False
@@ -746,6 +750,7 @@ async def test_get_dashboard_info_does_not_expose_access_list(mock_info, mcp_ser
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.published = True
@@ -802,6 +807,7 @@ async def test_get_dashboard_info_restricted_user_redacts_data_model_metadata(
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = json.dumps(
{
"native_filter_configuration": [
@@ -868,6 +874,7 @@ async def test_get_dashboard_info_restricted_user_redacts_permalink_filter_state
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.published = True
@@ -969,6 +976,7 @@ async def test_list_dashboards_omits_requested_user_directory_fields(
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.is_managed_externally = False
@@ -1019,6 +1027,7 @@ async def test_get_dashboard_info_includes_embedded_uuid(mock_find_object, mcp_s
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = "{}"
dashboard.published = True
dashboard.is_managed_externally = False
@@ -1062,6 +1071,7 @@ async def test_get_dashboard_info_embedded_uuid_none_when_not_embedded(
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = "{}"
dashboard.published = True
dashboard.is_managed_externally = False
@@ -1100,6 +1110,7 @@ async def test_get_dashboard_info_by_uuid(mock_find_object, mcp_server):
dashboard.css = ""
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = "{}"
dashboard.published = True
dashboard.is_managed_externally = False
@@ -1139,6 +1150,7 @@ async def test_get_dashboard_info_by_slug(mock_find_object, mcp_server):
dashboard.css = ""
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = "{}"
dashboard.published = True
dashboard.is_managed_externally = False
@@ -1190,6 +1202,7 @@ async def test_list_dashboards_custom_uuid_slug_columns(mock_list, mcp_server):
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.is_managed_externally = False
dashboard.external_url = None
@@ -1245,6 +1258,7 @@ async def test_list_dashboards_sanitizes_dashboard_descriptions_and_filter_text(
dashboard.dashboard_title = "Quarterly Dashboard"
dashboard.slug = "quarterly-dashboard"
dashboard.uuid = "uuid-quarterly-3"
+ dashboard.deleted_at = None
dashboard.url = "/dashboard/3"
dashboard.published = True
dashboard.changed_by_name = "admin"
@@ -1410,6 +1424,7 @@ class TestDashboardDefaultColumnFiltering:
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
+ dashboard.deleted_at = None
dashboard.json_metadata = None
dashboard.is_managed_externally = False
dashboard.external_url = None
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_list_dashboards_deleted_state.py b/tests/unit_tests/mcp_service/dashboard/tool/test_list_dashboards_deleted_state.py
new file mode 100644
index 00000000000..5cbd4c249cb
--- /dev/null
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_list_dashboards_deleted_state.py
@@ -0,0 +1,176 @@
+# 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 list_dashboards ``deleted_state`` trash listing.
+
+Mirrors the list_charts deleted_state tests: DAO custom filter pass-through,
+session-scoped visibility bypass around the DAO call, and ``deleted_at``
+forced into loaded columns and the serialized response.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from unittest.mock import MagicMock, Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+_DAO_LIST = "superset.daos.dashboard.DashboardDAO.list"
+_BYPASS = "superset.mcp_service.mcp_core.skip_visibility_filter"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ 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 _trashed_dashboard_row() -> Mock:
+ row = Mock(
+ spec=[
+ "id",
+ "dashboard_title",
+ "slug",
+ "published",
+ "changed_on",
+ "created_on",
+ "uuid",
+ "deleted_at",
+ ]
+ )
+ row.id = 1
+ row.dashboard_title = "Trashed Dashboard"
+ row.slug = None
+ row.published = False
+ row.changed_on = datetime(2026, 6, 1)
+ row.created_on = datetime(2026, 5, 1)
+ row.uuid = UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
+ row.deleted_at = datetime(2026, 7, 1)
+ return row
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_dashboards_deleted_state_only_passes_custom_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_dashboards", {"request": {"deleted_state": "only"}}
+ )
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" in (kwargs.get("custom_filters") or {})
+ assert "deleted_at" in kwargs["columns"]
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_dashboards_deleted_state_wraps_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ from superset.models.dashboard import Dashboard
+
+ mock_list.return_value = ([], 0)
+ mock_bypass.return_value.__enter__ = Mock(return_value=None)
+ mock_bypass.return_value.__exit__ = Mock(return_value=False)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_dashboards", {"request": {"deleted_state": "include"}}
+ )
+
+ mock_bypass.assert_called_once()
+ assert Dashboard in mock_bypass.call_args.args
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_dashboards_deleted_state_serializes_deleted_at(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([_trashed_dashboard_row()], 1)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_dashboards", {"request": {"deleted_state": "only"}}
+ )
+
+ data = json.loads(result.content[0].text)
+ assert data["dashboards"][0]["id"] == 1
+ assert data["dashboards"][0]["deleted_at"] is not None
+ assert "deleted_at" in data["columns_loaded"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_dashboards_default_has_no_deleted_state_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_dashboards", {})
+
+ kwargs = mock_list.call_args.kwargs
+ assert not kwargs.get("custom_filters")
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_dashboards_no_deleted_state_no_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_dashboards", {})
+
+ mock_bypass.assert_not_called()
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_dashboards_deleted_state_invalid_value_rejected(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ from fastmcp.exceptions import ToolError
+
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ with pytest.raises(ToolError):
+ await client.call_tool(
+ "list_dashboards", {"request": {"deleted_state": "everything"}}
+ )
diff --git a/tests/unit_tests/mcp_service/test_mcp_core.py b/tests/unit_tests/mcp_service/test_mcp_core.py
index c2d7456d532..0a4175e44ce 100644
--- a/tests/unit_tests/mcp_service/test_mcp_core.py
+++ b/tests/unit_tests/mcp_service/test_mcp_core.py
@@ -30,7 +30,11 @@ from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
-from superset.mcp_service.mcp_core import _slugify, ModelGetInfoCore
+from superset.mcp_service.mcp_core import (
+ _slugify,
+ ModelGetInfoCore,
+ ModelListCore,
+)
class _FakeOutput(BaseModel):
@@ -312,3 +316,70 @@ def test_explicit_title_column_overrides_dao_attribute() -> None:
)
def test_slugify_handles_edge_cases(identifier: str, expected_slug: str) -> None:
assert _slugify(identifier) == expected_slug
+
+
+def test_deleted_state_bound_filter_delegates_bound_value() -> None:
+ """The adapter passed to DAO custom_filters must forward the bound
+ deleted_state value — BaseDAO.list invokes custom filters with
+ ``apply(query, None)``, which the FAB filter would treat as 'live only'."""
+ from unittest.mock import Mock
+
+ from superset.mcp_service.mcp_core import DeletedStateBoundFilter
+
+ inner = Mock()
+ inner.apply.return_value = "filtered-query"
+ bound = DeletedStateBoundFilter(inner, "only", model=Mock)
+
+ assert bound.apply("query", None) == "filtered-query"
+ inner.apply.assert_called_once_with("query", "only")
+
+
+def _build_list_core(
+ deleted_state_filter: type | None = None,
+) -> "ModelListCore[Any]":
+ """Minimal ModelListCore for exercising _build_deleted_state_filter."""
+ from unittest.mock import MagicMock
+
+ from pydantic import BaseModel
+
+ class _Out(BaseModel):
+ pass
+
+ return ModelListCore(
+ dao_class=MagicMock(),
+ output_schema=_Out,
+ item_serializer=lambda obj, cols: None,
+ filter_type=_Out,
+ default_columns=["id"],
+ search_columns=[],
+ list_field_name="items",
+ output_list_schema=_Out,
+ deleted_state_filter=deleted_state_filter,
+ )
+
+
+def test_build_deleted_state_filter_none_short_circuits() -> None:
+ """deleted_state=None must engage no filter or bypass machinery."""
+ core = _build_list_core()
+ assert core._build_deleted_state_filter(None) is None
+
+
+def test_build_deleted_state_filter_rejects_invalid_value() -> None:
+ """The core re-validates defensively even though the request schemas
+ bound the value with Literal — a non-schema caller must still be
+ rejected before any visibility bypass engages."""
+ import pytest
+
+ core = _build_list_core()
+ with pytest.raises(ValueError, match="include.*only|'include' or 'only'"):
+ core._build_deleted_state_filter("everything")
+
+
+def test_build_deleted_state_filter_rejects_unconfigured_resource() -> None:
+ """Resources without a configured deleted-state filter reject the
+ parameter instead of silently returning live rows."""
+ import pytest
+
+ core = _build_list_core(deleted_state_filter=None)
+ with pytest.raises(ValueError, match="not supported"):
+ core._build_deleted_state_filter("only")