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