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