You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/08 13:20:16 UTC

(superset) branch master updated: fix(mcp): refuse changes to externally managed dashboards in all dashboard tools (#45062)

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 d27b7344087 fix(mcp): refuse changes to externally managed dashboards in all dashboard tools (#45062)
d27b7344087 is described below

commit d27b73440872e5d3d3bfe0aed8b5d47bd7dec64a
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Thu Oct 8 07:20:08 2026 -0600

    fix(mcp): refuse changes to externally managed dashboards in all dashboard tools (#45062)
    
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 UPDATING.md                                        |   5 +
 docs/admin_docs/configuration/mcp-server.mdx       |   9 +
 superset/mcp_service/dashboard/schemas.py          |  61 ++--
 .../tool/add_chart_to_existing_dashboard.py        |  11 +
 .../mcp_service/dashboard/tool/delete_dashboard.py |  66 +++-
 .../mcp_service/dashboard/tool/governance_utils.py |  15 +-
 .../tool/manage_dashboard_certification.py         |  10 +-
 .../dashboard/tool/manage_dashboard_markdown.py    |  15 +-
 .../dashboard/tool/manage_dashboard_owners.py      |   8 +
 .../dashboard/tool/manage_dashboard_roles.py       |  14 +-
 .../dashboard/tool/manage_native_filters.py        |  82 ++++-
 .../dashboard/tool/remove_chart_from_dashboard.py  |  12 +
 .../dashboard/tool/restore_dashboard.py            |  47 ++-
 .../mcp_service/dashboard/tool/update_dashboard.py |  10 +
 .../dashboard/tool/test_apply_dashboard_filters.py |   2 +
 .../dashboard/tool/test_dashboard_generation.py    |   1 +
 .../dashboard/tool/test_delete_dashboard.py        |   1 +
 .../dashboard/tool/test_manage_dashboard_owners.py |   1 +
 .../dashboard/tool/test_manage_dashboard_roles.py  |   2 +
 .../dashboard/tool/test_manage_native_filters.py   |   1 +
 .../tool/test_managed_dashboard_refusal.py         | 353 +++++++++++++++++++++
 .../dashboard/tool/test_restore_dashboard.py       |   1 +
 22 files changed, 647 insertions(+), 80 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 8049e0b888a..3151488da5e 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,11 @@ assists people when migrating to a new version.
 
 ## Next
 
+- MCP dashboard mutation tools refuse externally managed dashboards, including
+  owner and role changes. Update the dashboard in its external source of truth
+  instead; the response sets `managed_externally: true`. Read-only tools and
+  certification inspection are unaffected.
+
 - Example export (`/export_as_example/`) rejects dashboards whose charts or
   native-filter targets use semantic views; use the ordinary chart/dashboard
   bundle export instead.
diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index eefe60bcc53..56223b34537 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -110,6 +110,15 @@ retains its call's slot rather than exhausting cancellation capacity for new
 calls. One warning with the call ID records abandonment. A timeout does not guarantee that warehouse writes were
 rolled back, so do not blindly retry mutating SQL.
 
+## Externally managed dashboards
+
+MCP dashboard mutation tools refuse changes to externally managed dashboards,
+including ownership and access-role changes, layout and filter edits, chart
+membership, certification, deletion, and restoration. Their responses set
+`managed_externally: true` and explain that the external system is the source
+of truth; changing Superset permissions or retrying does not override it.
+Read-only dashboard tools and certification's inspect-only call remain available.
+
 ## Native filter defaults
 
 `manage_native_filters` accepts `default_value` for select filters. An empty
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index bee19f18882..fca4c13da97 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -682,7 +682,28 @@ class AddChartToDashboardRequest(BaseModel):
     )
 
 
-class AddChartToDashboardResponse(BaseModel):
+class DashboardMutationErrorFields(BaseModel):
+    """Shared error and permission fields for governance mutations."""
+
+    error: str | None = Field(None, description="Error message, if operation failed")
+    permission_denied: bool = Field(
+        default=False,
+        description=(
+            "True when the user lacks edit rights on the target dashboard. "
+            "Remediation: ask the user to grant access; do not retry as-is."
+        ),
+    )
+    managed_externally: bool = Field(
+        default=False,
+        description=(
+            "True when the mutation was refused because the dashboard is "
+            "managed externally. This is not an access denial: permissions "
+            "and retries cannot change it; its source of truth is external."
+        ),
+    )
+
+
+class AddChartToDashboardResponse(DashboardMutationErrorFields):
     """Response schema for adding chart to dashboard."""
 
     dashboard: DashboardInfo | None = Field(
@@ -694,7 +715,6 @@ class AddChartToDashboardResponse(BaseModel):
     position: dict[str, Any] | None = Field(
         None, description="Position information for the added chart"
     )
-    error: str | None = Field(None, description="Error message, if operation failed")
     permission_denied: bool = Field(
         default=False,
         description=(
@@ -717,7 +737,7 @@ class RemoveChartFromDashboardRequest(BaseModel):
     )
 
 
-class RemoveChartFromDashboardResponse(BaseModel):
+class RemoveChartFromDashboardResponse(DashboardMutationErrorFields):
     """Response schema for removing a chart from a dashboard."""
 
     dashboard: DashboardInfo | None = Field(
@@ -734,7 +754,6 @@ class RemoveChartFromDashboardResponse(BaseModel):
             "became empty as a result)."
         ),
     )
-    error: str | None = Field(None, description="Error message, if operation failed")
     permission_denied: bool = Field(
         default=False,
         description=(
@@ -1066,7 +1085,7 @@ class UpdateDashboardRequest(OmittedMeansUnchanged):
         return normalized
 
 
-class UpdateDashboardResponse(BaseModel):
+class UpdateDashboardResponse(DashboardMutationErrorFields):
     """Response schema for ``update_dashboard``.
 
     Distinct from ``GenerateDashboardResponse`` because the semantics
@@ -1078,7 +1097,6 @@ class UpdateDashboardResponse(BaseModel):
         None, description="The updated dashboard info, if successful"
     )
     dashboard_url: str | None = Field(None, description="URL to view the dashboard")
-    error: str | None = Field(None, description="Error message, if update failed")
     permission_denied: bool = Field(
         default=False,
         description=(
@@ -1174,28 +1192,6 @@ class ManageDashboardOwnersRequest(BaseModel):
         return self
 
 
-class DashboardMutationErrorFields(BaseModel):
-    """Shared error and permission fields for governance mutations."""
-
-    error: str | None = Field(None, description="Error message, if operation failed")
-    permission_denied: bool = Field(
-        default=False,
-        description=(
-            "True when the user lacks edit rights on the target dashboard. "
-            "Remediation: ask the user to grant access; do not retry as-is."
-        ),
-    )
-    managed_externally: bool = Field(
-        default=False,
-        description=(
-            "True when the mutation was refused because the dashboard is "
-            "managed externally. Structural, not an access denial: granting "
-            "permissions cannot resolve it and the call should not be "
-            "retried — the entity's source of truth lives outside Superset."
-        ),
-    )
-
-
 class ManageDashboardOwnersResponse(DashboardMutationErrorFields):
     """Response schema for ``manage_dashboard_owners``."""
 
@@ -2273,7 +2269,7 @@ class DeleteDashboardRequest(BaseModel):
         return value
 
 
-class DeleteDashboardResponse(BaseModel):
+class DeleteDashboardResponse(DashboardMutationErrorFields):
     """Result of a delete_dashboard operation."""
 
     success: bool = Field(description="Whether the dashboard was deleted")
@@ -2288,7 +2284,6 @@ class DeleteDashboardResponse(BaseModel):
         ),
     )
     message: str | None = Field(None, description="Human-readable outcome message")
-    error: str | None = Field(None, description="Error message if the delete failed")
     error_type: str | None = Field(None, description="Type of error if failed")
     permission_denied: bool = Field(
         False,
@@ -2605,7 +2600,7 @@ class ManageNativeFiltersRequest(BaseModel):
         return self
 
 
-class ManageNativeFiltersResponse(BaseModel):
+class ManageNativeFiltersResponse(DashboardMutationErrorFields):
     """Response schema for the manage_native_filters tool."""
 
     dashboard_id: int | None = Field(None, description="ID of the dashboard")
@@ -2628,7 +2623,6 @@ class ManageNativeFiltersResponse(BaseModel):
         default_factory=list,
         description="Final native filter configuration after the operation, in order",
     )
-    error: str | None = Field(None, description="Error message, if operation failed")
     permission_denied: bool = Field(
         default=False,
         description=(
@@ -3456,7 +3450,7 @@ class RestoreDashboardRequest(BaseModel):
         return value
 
 
-class RestoreDashboardResponse(BaseModel):
+class RestoreDashboardResponse(DashboardMutationErrorFields):
     """Result of a restore_dashboard operation."""
 
     success: bool = Field(description="Whether the dashboard was restored from trash")
@@ -3465,7 +3459,6 @@ class RestoreDashboardResponse(BaseModel):
         None, description="Title of the restored dashboard"
     )
     message: str | None = Field(None, description="Human-readable outcome message")
-    error: str | None = Field(None, description="Error message if the restore failed")
     error_type: str | None = Field(None, description="Type of error if failed")
     permission_denied: bool = Field(
         False,
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 363c1e6f7bd..f25cd2ffaa8 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
@@ -48,6 +48,9 @@ from superset.mcp_service.dashboard.schemas import (
     DashboardInfo,
     serialize_chart_summary,
 )
+from superset.mcp_service.dashboard.tool.governance_utils import (
+    managed_dashboard_refusal,
+)
 from superset.mcp_service.privacy import user_can_view_data_model_metadata
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
@@ -225,6 +228,8 @@ def add_chart_to_existing_dashboard(  # noqa: C901 — complexity is structural
     """
     Add chart to existing dashboard. Auto-positions in 2-column grid.
     Returns updated dashboard info.
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
     """
     try:
         from superset.commands.dashboard.update import UpdateDashboardCommand
@@ -235,6 +240,12 @@ def add_chart_to_existing_dashboard(  # noqa: C901 — complexity is structural
             if auth_error is not None:
                 return auth_error
 
+            refusal: str | None = managed_dashboard_refusal(dashboard)
+            if refusal is not None:
+                return AddChartToDashboardResponse(
+                    managed_externally=True, error=refusal
+                )
+
             # Get chart object for SQLAlchemy relationships and validation
             from superset import db
             from superset.models.slice import Slice
diff --git a/superset/mcp_service/dashboard/tool/delete_dashboard.py b/superset/mcp_service/dashboard/tool/delete_dashboard.py
index 4f7754903ff..42fa84f28e1 100644
--- a/superset/mcp_service/dashboard/tool/delete_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/delete_dashboard.py
@@ -39,6 +39,9 @@ from superset.mcp_service.dashboard.schemas import (
     DeleteDashboardRequest,
     DeleteDashboardResponse,
 )
+from superset.mcp_service.dashboard.tool.governance_utils import (
+    managed_dashboard_refusal,
+)
 
 if TYPE_CHECKING:
     from superset.models.dashboard import Dashboard
@@ -86,6 +89,53 @@ def _routes_to_soft_delete() -> bool:
     return issubclass(Dashboard, SoftDeleteMixin) and is_feature_enabled("SOFT_DELETE")
 
 
+def _pre_delete_refusal(
+    dashboard: "Dashboard | None", identifier: int | str
+) -> DeleteDashboardResponse | None:
+    """Reject a missing or externally managed dashboard before deleting it."""
+    if dashboard is None:
+        display_id: str = str(identifier)[:200]
+        message: str = (
+            f"No dashboard found with identifier: {display_id}. "
+            "Use list_dashboards to get valid dashboard IDs."
+        )
+        return DeleteDashboardResponse(
+            success=False, error=message, error_type="NotFound"
+        )
+
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is None:
+        return None
+
+    from superset import security_manager
+    from superset.exceptions import SupersetSecurityException
+
+    try:
+        security_manager.raise_for_editorship(dashboard)
+    except SupersetSecurityException:
+        return DeleteDashboardResponse(
+            success=False,
+            permission_denied=True,
+            error="You do not have permission to delete this dashboard.",
+            error_type="Forbidden",
+        )
+    except SQLAlchemyError:
+        _rollback()
+        logger.exception("Editorship check failed during delete_dashboard")
+        return DeleteDashboardResponse(
+            success=False,
+            error=(
+                "Failed to verify dashboard edit permission due to a database error."
+            ),
+            error_type="LookupFailed",
+        )
+    return DeleteDashboardResponse(
+        success=False,
+        managed_externally=True,
+        error=refusal,
+    )
+
+
 @tool(
     tags=["mutate"],
     class_permission_name="Dashboard",
@@ -102,6 +152,9 @@ async def delete_dashboard(
 ) -> DeleteDashboardResponse:
     """Delete a dashboard.
 
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
     Identify the dashboard by numeric ID, UUID string, or slug. When the
     ``SOFT_DELETE`` feature flag is enabled the dashboard is moved to trash and
     can be restored by an owner or Admin; otherwise the delete is permanent and
@@ -146,13 +199,12 @@ async def delete_dashboard(
             error="Dashboard lookup failed due to a database error.",
             error_type="LookupFailed",
         )
-    if not dashboard:
-        display_id = str(request.identifier)[:200]
-        msg = (
-            f"No dashboard found with identifier: {display_id}. "
-            "Use list_dashboards to get valid dashboard IDs."
-        )
-        return DeleteDashboardResponse(success=False, error=msg, error_type="NotFound")
+    pre_delete_refusal: DeleteDashboardResponse | None = _pre_delete_refusal(
+        dashboard, request.identifier
+    )
+    if pre_delete_refusal is not None:
+        return pre_delete_refusal
+    assert dashboard is not None
 
     dashboard_id = dashboard.id
     # Dashboard titles are user-controlled and must remain exact in responses.
diff --git a/superset/mcp_service/dashboard/tool/governance_utils.py b/superset/mcp_service/dashboard/tool/governance_utils.py
index 45c4137f869..6e1f69a7521 100644
--- a/superset/mcp_service/dashboard/tool/governance_utils.py
+++ b/superset/mcp_service/dashboard/tool/governance_utils.py
@@ -16,9 +16,7 @@
 # under the License.
 
 """
-Shared helpers for the dashboard governance tools
-(``manage_dashboard_owners`` / ``manage_dashboard_roles`` /
-``manage_dashboard_certification``).
+Shared helpers for MCP dashboard mutation tools.
 
 ``update_dashboard`` keeps its own variant of the lookup/authorization
 helper because its not-found contract differs — it returns a
@@ -118,3 +116,14 @@ def find_and_authorize_dashboard(
 def dashboard_url(dashboard: "Dashboard") -> str:
     """Build the user-facing dashboard URL, preferring slug over id."""
     return f"{get_superset_base_url()}/dashboard/{dashboard.slug or dashboard.id}/"
+
+
+def managed_dashboard_refusal(dashboard: "Dashboard") -> str | None:
+    """Return the shared refusal for an externally managed dashboard."""
+    if dashboard.is_managed_externally:
+        return (
+            f"Dashboard '{dashboard.dashboard_title}' (ID: {dashboard.id}) "
+            "is managed externally; its source of truth is the external system "
+            "and it cannot be changed here. Do not retry."
+        )
+    return None
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py b/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py
index 67582394d93..699304841ac 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py
@@ -38,6 +38,7 @@ from superset.mcp_service.dashboard.schemas import (
 from superset.mcp_service.dashboard.tool.governance_utils import (
     dashboard_url,
     find_and_authorize_dashboard,
+    managed_dashboard_refusal,
 )
 
 logger: logging.Logger = logging.getLogger(__name__)
@@ -107,14 +108,11 @@ def manage_dashboard_certification(
     # protect it — the refusal must live in the tool itself, after the
     # editorship check, where a caller with no edit rights keeps getting
     # the plain editorship denial.
-    if dashboard.is_managed_externally:
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is not None:
         return ManageDashboardCertificationResponse(
             managed_externally=True,
-            error=(
-                f"Dashboard '{dashboard.dashboard_title}' (ID: {dashboard.id}) "
-                "is managed externally; its certification is owned by the "
-                "external system and cannot be changed here."
-            ),
+            error=refusal,
         )
 
     changed_fields: list[str] = []
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_markdown.py b/superset/mcp_service/dashboard/tool/manage_dashboard_markdown.py
index b56eb50fc1b..611b27137d9 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_markdown.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_markdown.py
@@ -66,6 +66,7 @@ from superset.mcp_service.dashboard.schemas import (
 from superset.mcp_service.dashboard.tool.governance_utils import (
     dashboard_url,
     find_and_authorize_dashboard,
+    managed_dashboard_refusal,
 )
 from superset.utils import json
 
@@ -316,6 +317,9 @@ def manage_dashboard_markdown(  # noqa: C901
     """
     Add, update, and remove markdown/header/divider layout components.
 
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
     Companion to ``manage_native_filters``, but for the ``position_json``
     layout tree instead of ``json_metadata``: no need to hand-craft the full
     raw layout to add a text tile or section header. Markdown tiles are
@@ -348,17 +352,12 @@ def manage_dashboard_markdown(  # noqa: C901
         return auth_error
     assert dashboard is not None  # narrows for mypy
 
-    # Writing position_json directly bypasses UpdateDashboardCommand, so
-    # mirror its raise_if_managed_externally guard explicitly.
-    if dashboard.is_managed_externally:
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is not None:
         return ManageDashboardMarkdownResponse(
             dashboard_id=request.dashboard_id,
             managed_externally=True,
-            error=(
-                f"Dashboard {request.dashboard_id} is managed externally; its "
-                "layout is owned by the external system and cannot be "
-                "changed here."
-            ),
+            error=refusal,
         )
 
     try:
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py b/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py
index e304105c53b..fc8baae5f03 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py
@@ -47,6 +47,7 @@ from superset.mcp_service.dashboard.schemas import (
 from superset.mcp_service.dashboard.tool.governance_utils import (
     dashboard_url,
     find_and_authorize_dashboard,
+    managed_dashboard_refusal,
 )
 from superset.mcp_service.system.schemas import serialize_subject_object
 from superset.subjects.exceptions import SubjectsNotFoundValidationError
@@ -258,6 +259,9 @@ def manage_dashboard_owners(
     """
     Add or remove dashboard owners with explicit, safe operations.
 
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
     Owners can edit the dashboard, manage its charts, and delete it. Unlike
     ``update_dashboard``'s dropped ``owners`` field, this tool never accepts
     a full-replacement list — only ``add_owner_ids``/``remove_owner_ids`` —
@@ -302,6 +306,10 @@ def manage_dashboard_owners(
         return auth_error
     assert dashboard is not None  # narrows for mypy
 
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is not None:
+        return ManageDashboardOwnersResponse(managed_externally=True, error=refusal)
+
     try:
         current_owner_ids = _owner_user_ids(dashboard)
     except SQLAlchemyError as db_err:
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py b/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py
index 0ae010c7497..fa8df5199a4 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py
@@ -46,6 +46,7 @@ from superset.mcp_service.dashboard.schemas import (
 from superset.mcp_service.dashboard.tool.governance_utils import (
     dashboard_url,
     find_and_authorize_dashboard,
+    managed_dashboard_refusal,
 )
 from superset.mcp_service.system.schemas import serialize_subject_object
 from superset.subjects.types import SubjectType
@@ -168,10 +169,13 @@ def manage_dashboard_roles(
     request: ManageDashboardRolesRequest, ctx: Context
 ) -> ManageDashboardRolesResponse:
     """
-    Add or remove dashboard access roles with explicit operations.
+    Dashboard access roles restrict who can view a dashboard; add or remove
+    them with explicit operations.
 
-    Dashboard access roles restrict who can view a dashboard to members of
-    the listed roles, on top of normal Superset permissions. An empty roles
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
+    The listed roles apply on top of normal Superset permissions. An empty roles
     list means "no role restriction" — the dashboard is visible per standard
     permissions instead. This only takes effect when the ``ENABLE_VIEWERS``
     feature flag is enabled; the response's ``viewers_enabled`` field
@@ -217,6 +221,10 @@ def manage_dashboard_roles(
         return auth_error
     assert dashboard is not None  # narrows for mypy
 
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is not None:
+        return ManageDashboardRolesResponse(managed_externally=True, error=refusal)
+
     viewers_enabled = is_feature_enabled("ENABLE_VIEWERS")
     warnings: list[str] = []
     if not viewers_enabled:
diff --git a/superset/mcp_service/dashboard/tool/manage_native_filters.py b/superset/mcp_service/dashboard/tool/manage_native_filters.py
index d2c14a58d5a..67d1188ba4a 100644
--- a/superset/mcp_service/dashboard/tool/manage_native_filters.py
+++ b/superset/mcp_service/dashboard/tool/manage_native_filters.py
@@ -25,13 +25,15 @@ translating high-level operations into the ``deleted`` / ``modified`` /
 
 import copy
 import logging
-from typing import Any
+from typing import Any, TYPE_CHECKING
 
 from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
 from superset_core.mcp.decorators import tool, ToolAnnotations
 
 from superset.constants import EMPTY_FILTER_SQL_EXPRESSION, NULL_STRING
 from superset.dashboards.filter_scope import _is_divider
+from superset.exceptions import SupersetSecurityException
 from superset.extensions import event_logger
 from superset.mcp_service.dashboard.constants import generate_id
 from superset.mcp_service.dashboard.schemas import (
@@ -46,11 +48,17 @@ from superset.mcp_service.dashboard.schemas import (
     NativeFilterSummary,
     NativeFilterUpdateSpec,
 )
+from superset.mcp_service.dashboard.tool.governance_utils import (
+    managed_dashboard_refusal,
+)
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
 
 logger = logging.getLogger(__name__)
 
+if TYPE_CHECKING:
+    from superset.models.dashboard import Dashboard
+
 # Update fields that map to a filter's controlValues, keyed by the
 # NativeFilterUpdateSpec field name they come from.
 _CONTROL_VALUE_FIELDS: dict[str, str] = {
@@ -697,6 +705,60 @@ def _build_native_filters_payload(  # noqa: C901
     return payload, added_filter_ids, updated_filter_ids
 
 
+def _pre_filter_update_refusal(
+    dashboard: "Dashboard | None", dashboard_id: int
+) -> ManageNativeFiltersResponse | None:
+    """Reject a missing or externally managed dashboard before filter edits."""
+    if dashboard is None:
+        return ManageNativeFiltersResponse(
+            error=(
+                f"Dashboard with ID {dashboard_id} not found."
+                " Use list_dashboards to get valid dashboard IDs."
+            ),
+        )
+
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is None:
+        return None
+
+    from superset import security_manager
+
+    try:
+        security_manager.raise_for_editorship(dashboard)
+    except SupersetSecurityException:
+        return ManageNativeFiltersResponse(
+            dashboard_id=dashboard_id,
+            permission_denied=True,
+            error=(
+                f"You don't have permission to edit dashboard {dashboard_id}. "
+                "Changing native filters requires editorship of the dashboard."
+            ),
+        )
+    except SQLAlchemyError:
+        logger.exception(
+            "Database error checking editorship for dashboard %s", dashboard_id
+        )
+        from superset import db
+
+        try:
+            db.session.rollback()  # pylint: disable=consider-using-transaction
+        except SQLAlchemyError:
+            logger.warning(
+                "Database rollback failed during native filter error handling"
+            )
+        return ManageNativeFiltersResponse(
+            dashboard_id=dashboard_id,
+            error=(
+                "Failed to verify dashboard edit permission due to a database error."
+            ),
+        )
+    return ManageNativeFiltersResponse(
+        dashboard_id=dashboard_id,
+        managed_externally=True,
+        error=refusal,
+    )
+
+
 @tool(
     tags=["mutate"],
     class_permission_name="Dashboard",
@@ -713,7 +775,10 @@ def manage_native_filters(
     request: ManageNativeFiltersRequest, ctx: Context
 ) -> ManageNativeFiltersResponse:
     """
-    Add, update, remove, and reorder native filters on a dashboard.
+    Add, update, remove, or reorder dashboard native filters.
+
+    Externally managed dashboards refuse edits
+    (``managed_externally=True``); do not retry.
 
     Supported filter types for new filters: filter_select (dropdown backed
     by a dataset column), filter_time (time range), filter_range (numerical
@@ -749,13 +814,12 @@ def manage_native_filters(
     try:
         with event_logger.log_context(action="mcp.manage_native_filters.validation"):
             dashboard = DashboardDAO.find_by_id(request.dashboard_id)
-            if not dashboard:
-                return ManageNativeFiltersResponse(
-                    error=(
-                        f"Dashboard with ID {request.dashboard_id} not found."
-                        " Use list_dashboards to get valid dashboard IDs."
-                    ),
-                )
+            pre_filter_refusal: ManageNativeFiltersResponse | None = (
+                _pre_filter_update_refusal(dashboard, request.dashboard_id)
+            )
+            if pre_filter_refusal is not None:
+                return pre_filter_refusal
+            assert dashboard is not None
 
             current_config = current_native_filter_config(dashboard)
             dashboard_chart_ids = [slc.id for slc in dashboard.slices]
diff --git a/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py b/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
index 0557c499bf3..e5e110df531 100644
--- a/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
@@ -48,6 +48,9 @@ from superset.mcp_service.dashboard.schemas import (
     RemoveChartFromDashboardResponse,
     serialize_chart_summary,
 )
+from superset.mcp_service.dashboard.tool.governance_utils import (
+    managed_dashboard_refusal,
+)
 from superset.mcp_service.privacy import user_can_view_data_model_metadata
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
@@ -238,6 +241,9 @@ def remove_chart_from_dashboard(  # noqa: C901 — complexity is structural (lay
     """
     Remove a chart from an existing dashboard.
 
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
     Deletes the chart's layout component(s) from the dashboard (all
     occurrences, including under tabs), prunes rows/columns left empty by
     the removal, detaches the chart from the dashboard, and cleans stale
@@ -257,6 +263,12 @@ def remove_chart_from_dashboard(  # noqa: C901 — complexity is structural (lay
             if auth_error is not None:
                 return auth_error
 
+            refusal: str | None = managed_dashboard_refusal(dashboard)
+            if refusal is not None:
+                return RemoveChartFromDashboardResponse(
+                    managed_externally=True, error=refusal
+                )
+
         # Remove the chart from the layout tree
         with event_logger.log_context(action="mcp.remove_chart_from_dashboard.layout"):
             try:
diff --git a/superset/mcp_service/dashboard/tool/restore_dashboard.py b/superset/mcp_service/dashboard/tool/restore_dashboard.py
index 4fabbfc7c9b..dd1e54c78db 100644
--- a/superset/mcp_service/dashboard/tool/restore_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/restore_dashboard.py
@@ -20,7 +20,7 @@ MCP tool: restore_dashboard
 """
 
 import logging
-from typing import Any
+from typing import Any, TYPE_CHECKING
 
 from fastmcp import Context
 from sqlalchemy.exc import SQLAlchemyError
@@ -36,9 +36,15 @@ from superset.mcp_service.dashboard.schemas import (
     RestoreDashboardRequest,
     RestoreDashboardResponse,
 )
+from superset.mcp_service.dashboard.tool.governance_utils import (
+    managed_dashboard_refusal,
+)
 
 logger = logging.getLogger(__name__)
 
+if TYPE_CHECKING:
+    from superset.models.dashboard import Dashboard
+
 
 def _find_dashboard_for_restore(identifier: int | str) -> Any | None:
     """Resolve a dashboard by numeric ID or UUID, including soft-deleted rows.
@@ -71,6 +77,28 @@ def _rollback() -> None:
         )
 
 
+def _pre_restore_refusal(dashboard: "Dashboard") -> RestoreDashboardResponse | None:
+    """Reject externally managed dashboards and rows not in trash."""
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is not None:
+        return RestoreDashboardResponse(
+            success=False,
+            managed_externally=True,
+            error=refusal,
+        )
+
+    if dashboard.deleted_at is None:
+        return RestoreDashboardResponse(
+            success=False,
+            error=(
+                f"Dashboard '{dashboard.dashboard_title}' (id={dashboard.id}) is not "
+                "in trash; nothing to restore."
+            ),
+            error_type="NotDeleted",
+        )
+    return None
+
+
 @tool(
     tags=["mutate"],
     class_permission_name="Dashboard",
@@ -87,6 +115,9 @@ async def restore_dashboard(
 ) -> RestoreDashboardResponse:
     """Restore a soft-deleted dashboard from trash.
 
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
     Identify the dashboard by numeric ID or UUID string (slug lookup does not
     cover trashed dashboards). Only dashboards that were soft-deleted (moved
     to trash while the ``SOFT_DELETE`` feature flag was enabled) can be
@@ -173,15 +204,11 @@ async def restore_dashboard(
     # Dashboard titles are user-controlled and must remain exact in response text.
     dashboard_name = dashboard.dashboard_title
 
-    if dashboard.deleted_at is None:
-        return RestoreDashboardResponse(
-            success=False,
-            error=(
-                f"Dashboard '{dashboard_name}' (id={dashboard_id}) is not in "
-                "trash; nothing to restore."
-            ),
-            error_type="NotDeleted",
-        )
+    pre_restore_refusal: RestoreDashboardResponse | None = _pre_restore_refusal(
+        dashboard
+    )
+    if pre_restore_refusal is not None:
+        return pre_restore_refusal
 
     # The try/except sits inside log_context so failed restore attempts are
     # recorded in the audit log too — the context manager does not log when
diff --git a/superset/mcp_service/dashboard/tool/update_dashboard.py b/superset/mcp_service/dashboard/tool/update_dashboard.py
index dbf11357c74..a1264a0d965 100644
--- a/superset/mcp_service/dashboard/tool/update_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/update_dashboard.py
@@ -43,6 +43,9 @@ from superset.mcp_service.dashboard.schemas import (
     UpdateDashboardRequest,
     UpdateDashboardResponse,
 )
+from superset.mcp_service.dashboard.tool.governance_utils import (
+    managed_dashboard_refusal,
+)
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
 
@@ -310,6 +313,9 @@ async def update_dashboard(
 ) -> UpdateDashboardResponse | DashboardError:
     """Patch an existing dashboard's layout, theme, styling, or metadata.
 
+    Externally managed dashboards refuse mutations with
+    ``managed_externally=True``; do not retry or request more permissions.
+
     Companion to ``generate_dashboard`` for incremental metadata and styling
     edits. An LLM can:
 
@@ -344,6 +350,10 @@ async def update_dashboard(
     if auth_error is not None:
         return auth_error
 
+    refusal: str | None = managed_dashboard_refusal(dashboard)
+    if refusal is not None:
+        return UpdateDashboardResponse(managed_externally=True, error=refusal)
+
     validation_error: DashboardError | None = _validate_update_request(
         dashboard, request
     )
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
index 244322110db..7242dcfe78f 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
@@ -143,6 +143,7 @@ def _mock_dashboard(filters: list[dict[str, Any]] | None = None, id: int = 1) ->
     """Build a mock dashboard carrying the given native filter config."""
     dashboard = Mock()
     dashboard.id = id
+    dashboard.is_managed_externally = False
     dashboard.uuid = "8f2ab3c4-0000-4000-8000-000000000001"
     dashboard.dashboard_title = "Test Dashboard"
     dashboard.json_metadata = json.dumps({"native_filter_configuration": filters or []})
@@ -1043,6 +1044,7 @@ async def test_timegrain_filter_created_by_manage_native_filters_accepts_a_grain
 
     create_dashboard = Mock()
     create_dashboard.id = 1
+    create_dashboard.is_managed_externally = False
     create_dashboard.dashboard_title = "Test Dashboard"
     create_dashboard.json_metadata = json.dumps({"native_filter_configuration": []})
     create_dashboard.slices = []
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 c4a726579b4..776c1e247be 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
@@ -118,6 +118,7 @@ def _mock_dashboard(id: int = 1, title: str = "Test Dashboard") -> Mock:
     """Create a mock dashboard object."""
     dashboard = Mock()
     dashboard.id = id
+    dashboard.is_managed_externally = False
     dashboard.dashboard_title = title
     dashboard.slug = f"test-dashboard-{id}"
     dashboard.description = "Test dashboard description"
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_delete_dashboard.py b/tests/unit_tests/mcp_service/dashboard/tool/test_delete_dashboard.py
index 1a04e30a913..7b5c3c8a153 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_delete_dashboard.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_delete_dashboard.py
@@ -58,6 +58,7 @@ def _mock_dashboard(dashboard_id: int = 1, title: str = "Sales Dashboard") -> Mo
     dashboard = Mock()
     dashboard.id = dashboard_id
     dashboard.dashboard_title = title
+    dashboard.is_managed_externally = False
     return dashboard
 
 
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_owners.py b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_owners.py
index d755671193f..f59a8d5070a 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_owners.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_owners.py
@@ -69,6 +69,7 @@ def _mock_dashboard(
     dashboard.id = id
     dashboard.dashboard_title = title
     dashboard.slug = slug
+    dashboard.is_managed_externally = False
     dashboard.editors = (
         editors if editors is not None else [_mock_subject(100, 1, "admin")]
     )
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_roles.py b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_roles.py
index 5b09170972c..3747eb45272 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_roles.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_dashboard_roles.py
@@ -69,6 +69,7 @@ def _mock_dashboard(
     dashboard.id = id
     dashboard.dashboard_title = title
     dashboard.slug = slug
+    dashboard.is_managed_externally = False
     dashboard.viewers = viewers if viewers is not None else []
     return dashboard
 
@@ -224,6 +225,7 @@ class TestManageDashboardRoles:
             id: int = 42
             dashboard_title: str = "Test Dashboard"
             slug: str = "test-slug"
+            is_managed_externally: bool = False
 
             @property
             def viewers(self) -> list[Mock]:
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py
index 379e8a65cb3..c515d42f9ac 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py
@@ -154,6 +154,7 @@ def _mock_dashboard(
     """Build a mock dashboard with the given native filters and chart slices."""
     dashboard = Mock()
     dashboard.id = id
+    dashboard.is_managed_externally = False
     dashboard.dashboard_title = "Test Dashboard"
     dashboard.json_metadata = json.dumps({"native_filter_configuration": filters or []})
     slices = []
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_managed_dashboard_refusal.py b/tests/unit_tests/mcp_service/dashboard/tool/test_managed_dashboard_refusal.py
new file mode 100644
index 00000000000..3315cffcc54
--- /dev/null
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_managed_dashboard_refusal.py
@@ -0,0 +1,353 @@
+# 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.
+
+"""Every MCP mutation of an externally managed dashboard refuses the write."""
+
+from collections.abc import Iterator
+from contextlib import ExitStack, nullcontext
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.exceptions import SupersetSecurityException
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[None]:
+    with (
+        patch("superset.mcp_service.auth.get_user_from_request") as mock_user,
+        patch("superset.security_manager.raise_for_editorship"),
+    ):
+        mock_user.return_value = Mock(id=1, username="admin")
+        yield
+
+
+@pytest.mark.parametrize(
+    ("tool_name", "tool_request"),
+    [
+        ("manage_dashboard_owners", {"identifier": 42, "add_owner_ids": [7]}),
+        ("manage_dashboard_roles", {"identifier": 42, "add_role_ids": [5]}),
+        ("manage_dashboard_certification", {"identifier": 42, "certified_by": "QA"}),
+        ("manage_dashboard_markdown", {"dashboard_id": 42, "remove": ["HEADER-1"]}),
+        ("update_dashboard", {"identifier": 42, "dashboard_title": "Changed"}),
+        ("add_chart_to_existing_dashboard", {"dashboard_id": 42, "chart_id": 9}),
+        ("remove_chart_from_dashboard", {"dashboard_id": 42, "chart_id": 9}),
+        ("manage_native_filters", {"dashboard_id": 42, "reorder": []}),
+        ("delete_dashboard", {"identifier": 42}),
+        ("restore_dashboard", {"identifier": 42}),
+    ],
+)
+@pytest.mark.asyncio
+async def test_managed_dashboard_mutation_refused(
+    tool_name: str, tool_request: dict[str, Any]
+) -> None:
+    dashboard: Mock = Mock()
+    dashboard.id = 42
+    dashboard.dashboard_title = "Externally managed"
+    dashboard.slug = "externally-managed"
+    dashboard.uuid = UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
+    dashboard.is_managed_externally = True
+    dashboard.deleted_at = datetime(2026, 1, 1)
+    dashboard.json_metadata = json.dumps({"native_filter_configuration": []})
+    dashboard.position_json = "{}"
+    dashboard.slices = []
+    dashboard.editors = []
+    dashboard.viewers = []
+
+    with (
+        patch(
+            "superset.daos.dashboard.DashboardDAO.get_by_id_or_slug",
+            return_value=dashboard,
+        ),
+        patch(
+            "superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard
+        ),
+        patch(
+            "superset.daos.dashboard.DashboardDAO.find_by_id_or_uuid",
+            return_value=dashboard,
+        ),
+        patch(
+            "superset.mcp_service.dashboard.tool.delete_dashboard._find_dashboard_by_identifier",
+            return_value=dashboard,
+        ),
+        patch(
+            "superset.mcp_service.dashboard.tool.restore_dashboard._find_dashboard_for_restore",
+            return_value=dashboard,
+        ),
+        patch(
+            "superset.extensions.event_logger.log_context", return_value=nullcontext()
+        ),
+        patch("superset.extensions.db.session") as session,
+    ):
+        async with Client(mcp) as client:
+            result = await client.call_tool(tool_name, {"request": tool_request})
+
+    response: dict[str, Any] = result.structured_content
+    assert response["managed_externally"] is True
+    assert response["permission_denied"] is False
+    assert "managed externally" in response["error"]
+    assert "cannot be changed here" in response["error"]
+    session.commit.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    ("tool_name", "lookup", "tool_request"),
+    [
+        (
+            "manage_native_filters",
+            "superset.daos.dashboard.DashboardDAO.find_by_id",
+            {"dashboard_id": 42, "reorder": []},
+        ),
+        (
+            "delete_dashboard",
+            "superset.mcp_service.dashboard.tool.delete_dashboard._find_dashboard_by_identifier",
+            {"identifier": 42},
+        ),
+    ],
+)
+@pytest.mark.asyncio
+async def test_non_editor_cannot_probe_managed_status(
+    tool_name: str, lookup: str, tool_request: dict[str, Any]
+) -> None:
+    dashboard: Mock = Mock(id=42, dashboard_title="Hidden dashboard")
+    dashboard.is_managed_externally = True
+
+    with (
+        patch(lookup, return_value=dashboard),
+        patch(
+            "superset.security_manager.raise_for_editorship",
+            side_effect=SupersetSecurityException(Mock(message="forbidden")),
+        ),
+    ):
+        async with Client(mcp) as client:
+            result = await client.call_tool(tool_name, {"request": tool_request})
+
+    response: dict[str, Any] = result.structured_content
+    assert response["permission_denied"] is True
+    assert response["managed_externally"] is False
+    assert "Hidden dashboard" not in (response["error"] or "")
+
+
+@pytest.mark.asyncio
+async def test_native_filter_managed_editorship_database_error_is_structured() -> None:
+    dashboard: Mock = Mock(id=42, dashboard_title="Externally managed")
+    dashboard.is_managed_externally = True
+
+    with (
+        patch(
+            "superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard
+        ),
+        patch(
+            "superset.security_manager.raise_for_editorship",
+            side_effect=SQLAlchemyError("private database detail"),
+        ),
+        patch("superset.extensions.db.session") as session,
+    ):
+        async with Client(mcp) as client:
+            result = await client.call_tool(
+                "manage_native_filters",
+                {"request": {"dashboard_id": 42, "reorder": []}},
+            )
+
+    response: dict[str, Any] = result.structured_content
+    assert "database error" in (response["error"] or "")
+    assert "private database detail" not in (response["error"] or "")
+    assert response["managed_externally"] is False
+    session.rollback.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    ("tool_name", "tool_request", "write_kind"),
+    [
+        ("manage_dashboard_owners", {"identifier": 42, "add_owner_ids": [7]}, "owners"),
+        ("manage_dashboard_roles", {"identifier": 42, "add_role_ids": [5]}, "commit"),
+        (
+            "manage_dashboard_certification",
+            {"identifier": 42, "certified_by": "QA"},
+            "commit",
+        ),
+        (
+            "manage_dashboard_markdown",
+            {"dashboard_id": 42, "add": [{"component_type": "header", "text": "X"}]},
+            "commit",
+        ),
+        (
+            "update_dashboard",
+            {"identifier": 42, "dashboard_title": "Changed"},
+            "commit",
+        ),
+        (
+            "add_chart_to_existing_dashboard",
+            {"dashboard_id": 42, "chart_id": 9},
+            "update",
+        ),
+        ("remove_chart_from_dashboard", {"dashboard_id": 42, "chart_id": 9}, "update"),
+        ("manage_native_filters", {"dashboard_id": 42, "reorder": []}, "filters"),
+        ("delete_dashboard", {"identifier": 42}, "delete"),
+        ("restore_dashboard", {"identifier": 42}, "restore"),
+    ],
+)
+@pytest.mark.asyncio
+async def test_unmanaged_dashboard_reaches_write_path(
+    tool_name: str, tool_request: dict[str, Any], write_kind: str
+) -> None:
+    dashboard: Mock = Mock()
+    dashboard.id = 42
+    dashboard.dashboard_title = "Unmanaged dashboard"
+    dashboard.slug = "unmanaged"
+    dashboard.uuid = UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
+    dashboard.is_managed_externally = False
+    dashboard.deleted_at = datetime(2026, 1, 1)
+    dashboard.json_metadata = json.dumps({"native_filter_configuration": []})
+    dashboard.position_json = "{}"
+    dashboard.slices = []
+    dashboard.editors = []
+    dashboard.viewers = []
+    dashboard.tags = []
+    dashboard.published = True
+    dashboard.description = None
+    dashboard.css = None
+    dashboard.certified_by = None
+    dashboard.certification_details = None
+    dashboard.external_url = None
+    dashboard.embedded = []
+    dashboard.created_on = datetime(2026, 1, 1)
+    dashboard.changed_on = datetime(2026, 1, 1)
+    dashboard.created_by_name = "admin"
+    dashboard.changed_by_name = "admin"
+    dashboard.created_on_humanized = "Jan 2026"
+    dashboard.changed_on_humanized = "Jan 2026"
+    dashboard.created_by = Mock(username="admin")
+    dashboard.changed_by = Mock(username="admin")
+    chart: Mock = Mock(id=9, slice_name="Chart", tags=[], editors=[])
+    if tool_name == "remove_chart_from_dashboard":
+        dashboard.slices = [chart]
+
+    with ExitStack() as stack:
+        stack.enter_context(
+            patch(
+                "superset.daos.dashboard.DashboardDAO.get_by_id_or_slug",
+                return_value=dashboard,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.daos.dashboard.DashboardDAO.find_by_id",
+                return_value=dashboard,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.daos.dashboard.DashboardDAO.find_by_id_or_uuid",
+                return_value=dashboard,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.mcp_service.dashboard.tool.delete_dashboard._find_dashboard_by_identifier",
+                return_value=dashboard,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.mcp_service.dashboard.tool.restore_dashboard._find_dashboard_for_restore",
+                return_value=dashboard,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.extensions.event_logger.log_context",
+                return_value=nullcontext(),
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.mcp_service.dashboard.tool.manage_dashboard_owners._owner_user_ids",
+                return_value=[1],
+            )
+        )
+        owner_write: Mock = stack.enter_context(
+            patch(
+                "superset.mcp_service.dashboard.tool.manage_dashboard_owners._apply_owner_change",
+                return_value=({1, 7}, [], None),
+            )
+        )
+        stack.enter_context(patch("superset.is_feature_enabled", return_value=True))
+        role_subject: Mock = Mock(id=5, role_id=5)
+        stack.enter_context(
+            patch(
+                "superset.subjects.utils.get_or_create_role_subject",
+                return_value=role_subject,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.mcp_service.dashboard.tool.manage_dashboard_roles.serialize_subject_object",
+                return_value=None,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.mcp_service.dashboard.tool.delete_dashboard._routes_to_soft_delete",
+                return_value=False,
+            )
+        )
+        stack.enter_context(
+            patch(
+                "superset.mcp_service.auth.check_chart_data_access",
+                return_value=Mock(is_valid=True),
+            )
+        )
+        session: Mock = stack.enter_context(patch("superset.extensions.db.session"))
+        session.get.return_value = chart
+        update_command: Mock = stack.enter_context(
+            patch("superset.commands.dashboard.update.UpdateDashboardCommand")
+        )
+        update_command.return_value.run.return_value = dashboard
+        filter_command: Mock = stack.enter_context(
+            patch(
+                "superset.commands.dashboard.update.UpdateDashboardNativeFiltersCommand"
+            )
+        )
+        filter_command.return_value.run.return_value = []
+        delete_command: Mock = stack.enter_context(
+            patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
+        )
+        restore_command: Mock = stack.enter_context(
+            patch("superset.commands.dashboard.restore.RestoreDashboardCommand")
+        )
+
+        async with Client(mcp) as client:
+            result = await client.call_tool(tool_name, {"request": tool_request})
+
+    writes: dict[str, Mock] = {
+        "owners": owner_write,
+        "commit": session.commit,
+        "update": update_command.return_value.run,
+        "filters": filter_command.return_value.run,
+        "delete": delete_command.return_value.run,
+        "restore": restore_command.return_value.run,
+    }
+    writes[write_kind].assert_called()
+    assert result.structured_content["managed_externally"] is False
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_restore_dashboard.py b/tests/unit_tests/mcp_service/dashboard/tool/test_restore_dashboard.py
index 576b0d5bd6d..5e9561dfccd 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_restore_dashboard.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_restore_dashboard.py
@@ -65,6 +65,7 @@ def _mock_dashboard(
     dashboard = Mock()
     dashboard.id = dashboard_id
     dashboard.dashboard_title = title
+    dashboard.is_managed_externally = False
     dashboard.uuid = _UUID
     dashboard.deleted_at = datetime(2026, 7, 1) if deleted else None
     return dashboard