Compare commits

..

1 Commits

Author SHA1 Message Date
Amin Ghadersohi
97d20ac6f7 feat(mcp): add delete_dashboard tool 2026-06-11 00:02:40 +00:00
8 changed files with 355 additions and 138 deletions

View File

@@ -103,19 +103,6 @@ class DatasourceTypeUpdateRequiredValidationError(ValidationError):
)
class ChartQueryContextDatasourceMismatchValidationError(ValidationError):
"""
Raised when a query-context-only update carries a datasource that does not
match the chart's own datasource.
"""
def __init__(self) -> None:
super().__init__(
_("The query context datasource does not match the chart datasource"),
field_name="query_context",
)
class ChartNotFoundError(CommandException):
message = "Chart not found."

View File

@@ -29,7 +29,6 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
ChartQueryContextDatasourceMismatchValidationError,
ChartUpdateFailedError,
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
@@ -42,7 +41,6 @@ from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
from superset.utils import json
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -103,51 +101,6 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if not security_manager.is_owner(dash):
raise DashboardsForbiddenError()
def _validate_query_context_datasource(
self, exceptions: list[ValidationError]
) -> None:
"""
Ensure a query-context-only update keeps the chart's own datasource.
The submitted query context is only verified when it carries a parseable
``datasource`` object; a payload that references a different datasource than
the chart's persisted one is rejected. Payloads without a datasource fall
back to the chart's datasource at execution time and need no check.
"""
if not self._model:
return
raw_query_context = self._properties.get("query_context")
if not raw_query_context:
return
try:
query_context = json.loads(raw_query_context)
except (TypeError, ValueError):
# An unparseable payload cannot be verified or replayed; leave it for
# downstream handling rather than guessing at its intent.
return
datasource = (
query_context.get("datasource") if isinstance(query_context, dict) else None
)
if not isinstance(datasource, dict):
return
try:
ids_match = int(datasource["id"]) == self._model.datasource_id
except (KeyError, TypeError, ValueError):
ids_match = False
datasource_type = datasource.get("type")
types_match = (
datasource_type is None
or str(datasource_type) == self._model.datasource_type
)
if not ids_match or not types_match:
exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
def validate(self) -> None: # noqa: C901
exceptions: list[ValidationError] = []
dashboard_ids = self._properties.get("dashboards")
@@ -181,12 +134,6 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
raise ChartForbiddenError() from ex
except ValidationError as ex:
exceptions.append(ex)
else:
# The query-context-only path skips the ownership check so report and
# alert workers can refresh a chart's cached payload. Keep that payload
# bound to the chart's own datasource so it cannot be repointed at an
# unrelated one.
self._validate_query_context_datasource(exceptions)
# validate tags
try:

View File

@@ -130,6 +130,7 @@ Dashboard Management:
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
- delete_dashboard: Permanently delete a dashboard by ID; requires confirm=true (requires write access)
Annotation Layers:
- list_annotation_layers: List annotation layers with advanced filters (1-based pagination)
@@ -679,6 +680,7 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
)
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
delete_dashboard,
generate_dashboard,
get_dashboard_info,
get_dashboard_layout,

View File

@@ -598,6 +598,60 @@ class AddChartToDashboardResponse(BaseModel):
return sanitize_for_llm_context(value, field_path=("error",))
class DeleteDashboardRequest(BaseModel):
"""Request schema for deleting a dashboard."""
dashboard_id: int = Field(..., description="ID of the dashboard to delete")
confirm: bool = Field(
...,
description=(
"Explicit confirmation of the deletion. Deleting a dashboard is "
"permanent and cannot be undone. The tool refuses to delete unless "
"this is set to true."
),
)
class DeletedDashboardSummary(BaseModel):
"""Summary of a dashboard targeted for deletion."""
id: int = Field(..., description="ID of the dashboard")
dashboard_title: str | None = Field(None, description="Title of the dashboard")
slug: str | None = Field(None, description="Slug of the dashboard")
@field_validator("dashboard_title", "slug")
@classmethod
def sanitize_text_for_llm_context(cls, value: str | None) -> str | None:
"""Wrap user-controlled dashboard text before LLM exposure."""
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("dashboard",))
class DeleteDashboardResponse(BaseModel):
"""Response schema for deleting a dashboard."""
deleted: bool = Field(
False, description="True when the dashboard was permanently deleted"
)
dashboard: DeletedDashboardSummary | None = Field(
None, description="Summary of the deleted (or targeted) dashboard"
)
error: str | None = Field(None, description="Error message, if operation failed")
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
"""Wrap error text before it is exposed to LLM context.
The error may echo the dashboard-controlled title — it must be wrapped
so the LLM treats it as data, not instructions.
"""
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("error",))
class GenerateDashboardRequest(BaseModel):
"""Request schema for generating a dashboard."""

View File

@@ -16,6 +16,7 @@
# under the License.
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .delete_dashboard import delete_dashboard
from .generate_dashboard import generate_dashboard
from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
@@ -27,4 +28,5 @@ __all__ = [
"get_dashboard_layout",
"generate_dashboard",
"add_chart_to_existing_dashboard",
"delete_dashboard",
]

View File

@@ -0,0 +1,144 @@
# 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.
"""
MCP tool: delete_dashboard
This tool permanently deletes a dashboard. It requires an explicit
``confirm=true`` safety gate so callers must state destructive intent.
"""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
DeleteDashboardRequest,
DeleteDashboardResponse,
DeletedDashboardSummary,
)
logger = logging.getLogger(__name__)
@tool(
tags=["mutate"],
class_permission_name="Dashboard",
method_permission_name="write",
annotations=ToolAnnotations(
title="Delete dashboard",
readOnlyHint=False,
destructiveHint=True,
),
)
async def delete_dashboard(
request: DeleteDashboardRequest, ctx: Context
) -> DeleteDashboardResponse:
"""
Permanently delete a dashboard by ID. The charts on the dashboard are
NOT deleted — only the dashboard itself. This action cannot be undone,
so the tool refuses to run unless ``confirm=true`` is explicitly passed.
"""
from superset.commands.dashboard.delete import DeleteDashboardCommand
from superset.commands.dashboard.exceptions import (
DashboardDeleteFailedError,
DashboardForbiddenError,
DashboardNotFoundError,
)
from superset.daos.dashboard import DashboardDAO
if not request.confirm:
await ctx.warning(
"Deletion of dashboard %s not confirmed" % (request.dashboard_id,)
)
return DeleteDashboardResponse(
deleted=False,
dashboard=None,
error=(
f"Deletion not confirmed. Deleting dashboard "
f"{request.dashboard_id} is permanent and cannot be undone. "
"Re-run with confirm=true to proceed."
),
)
summary: DeletedDashboardSummary | None = None
try:
with event_logger.log_context(action="mcp.delete_dashboard.validation"):
dashboard = DashboardDAO.find_by_id(request.dashboard_id)
if not dashboard:
return DeleteDashboardResponse(
deleted=False,
dashboard=None,
error=(
f"Dashboard with ID {request.dashboard_id} not found. "
"Use list_dashboards to get valid dashboard IDs."
),
)
summary = DeletedDashboardSummary(
id=dashboard.id,
dashboard_title=dashboard.dashboard_title,
slug=dashboard.slug,
)
with event_logger.log_context(action="mcp.delete_dashboard.delete"):
DeleteDashboardCommand([request.dashboard_id]).run()
logger.info("Deleted dashboard %s", request.dashboard_id)
await ctx.info("Deleted dashboard %s" % (request.dashboard_id,))
return DeleteDashboardResponse(deleted=True, dashboard=summary, error=None)
except DashboardNotFoundError:
return DeleteDashboardResponse(
deleted=False,
dashboard=None,
error=(
f"Dashboard with ID {request.dashboard_id} not found. "
"Use list_dashboards to get valid dashboard IDs."
),
)
except DashboardForbiddenError:
await ctx.warning(
"Permission denied deleting dashboard %s" % (request.dashboard_id,)
)
return DeleteDashboardResponse(
deleted=False,
dashboard=summary,
error=(
f"You don't have permission to delete dashboard "
f"with ID {request.dashboard_id}."
),
)
except DashboardDeleteFailedError as exc:
await ctx.error(
"Failed to delete dashboard %s: %s" % (request.dashboard_id, str(exc))
)
return DeleteDashboardResponse(
deleted=False,
dashboard=summary,
error=f"Failed to delete dashboard {request.dashboard_id}: {exc.message}",
)
except Exception as exc:
await ctx.error(
"Unexpected error deleting dashboard: %s: %s"
% (type(exc).__name__, str(exc))
)
raise

View File

@@ -17,11 +17,10 @@
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.chart.exceptions import ChartForbiddenError
from superset.commands.chart.update import UpdateChartCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.utils import json
def _ownership_exc() -> SupersetSecurityException:
@@ -92,73 +91,3 @@ def test_update_chart_owner_can_perform_regular_update(
find_by_id.assert_called_once_with(1)
raise_for_ownership.assert_called_once()
def _query_context_payload(datasource: object) -> dict[str, object]:
return {
"query_context": json.dumps({"datasource": datasource, "queries": []}),
"query_context_generation": True,
}
def test_update_chart_query_context_matching_datasource_is_allowed(
mocker: MockerFixture,
) -> None:
"""A query context that targets the chart's own datasource is accepted."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
UpdateChartCommand(
1, _query_context_payload({"id": 42, "type": "table"})
).validate()
@pytest.mark.parametrize(
"datasource",
[
{"id": 99, "type": "table"}, # different id
{"id": 42, "type": "query"}, # different type
{"id": "99", "type": "table"}, # different id as string
],
)
def test_update_chart_query_context_mismatched_datasource_is_rejected(
mocker: MockerFixture,
datasource: dict[str, object],
) -> None:
"""A query context pointing at a different datasource is rejected with a 4xx."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
with pytest.raises(ChartInvalidError):
UpdateChartCommand(1, _query_context_payload(datasource)).validate()
@pytest.mark.parametrize(
"query_context",
[
"{}", # no datasource key
'{"datasource": null}', # null datasource
"not-json", # unparseable payload
],
)
def test_update_chart_query_context_without_datasource_is_allowed(
mocker: MockerFixture,
query_context: str,
) -> None:
"""Payloads with no verifiable datasource fall back to the chart's own."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
UpdateChartCommand(
1,
{"query_context": query_context, "query_context_generation": True},
).validate()

View File

@@ -0,0 +1,152 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Unit tests for the delete_dashboard MCP tool.
Covers:
- Successful delete (happy path)
- confirm=false refusal (safety gate)
- Dashboard not found
- Permission denied (user does not own the dashboard)
"""
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
@pytest.fixture
def mcp_server() -> object:
"""Return the FastMCP app instance for use in MCP client tests."""
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
"""Mock authentication for all tests."""
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
def _mock_dashboard(id: int = 1, title: str = "Sales Dashboard") -> Mock:
"""Create a minimal mock Dashboard object."""
dashboard = Mock()
dashboard.id = id
dashboard.dashboard_title = title
dashboard.slug = f"test-dashboard-{id}"
return dashboard
@patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_successful_delete(
mock_find_by_id: Mock, mock_delete_cmd_cls: Mock, mcp_server: object
) -> None:
"""Happy path: dashboard deleted, summary echoed back."""
mock_find_by_id.return_value = _mock_dashboard(id=1, title="Sales Dashboard")
mock_delete_cmd = Mock()
mock_delete_cmd.run.return_value = None
mock_delete_cmd_cls.return_value = mock_delete_cmd
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard",
{"request": {"dashboard_id": 1, "confirm": True}},
)
content = result.structured_content
assert content["deleted"] is True
assert content["error"] is None
assert content["dashboard"]["id"] == 1
assert "Sales Dashboard" in content["dashboard"]["dashboard_title"]
assert "test-dashboard-1" in content["dashboard"]["slug"]
mock_delete_cmd_cls.assert_called_once_with([1])
mock_delete_cmd.run.assert_called_once()
@patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_not_confirmed_refusal(
mock_find_by_id: Mock, mock_delete_cmd_cls: Mock, mcp_server: object
) -> None:
"""confirm=false: the tool refuses and nothing is deleted."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard",
{"request": {"dashboard_id": 1, "confirm": False}},
)
content = result.structured_content
assert content["deleted"] is False
assert content["dashboard"] is None
assert "confirm" in (content["error"] or "").lower()
mock_find_by_id.assert_not_called()
mock_delete_cmd_cls.assert_not_called()
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_dashboard_not_found(mock_find_by_id: Mock, mcp_server: object) -> None:
"""Returns a clear error when the target dashboard does not exist."""
mock_find_by_id.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard",
{"request": {"dashboard_id": 999, "confirm": True}},
)
content = result.structured_content
assert content["deleted"] is False
assert content["dashboard"] is None
assert "not found" in (content["error"] or "").lower()
@patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_permission_denied(
mock_find_by_id: Mock, mock_delete_cmd_cls: Mock, mcp_server: object
) -> None:
"""Returns a structured error when the user cannot delete the dashboard."""
from superset.commands.dashboard.exceptions import DashboardForbiddenError
mock_find_by_id.return_value = _mock_dashboard(id=1, title="Sales Dashboard")
mock_delete_cmd = Mock()
mock_delete_cmd.run.side_effect = DashboardForbiddenError()
mock_delete_cmd_cls.return_value = mock_delete_cmd
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard",
{"request": {"dashboard_id": 1, "confirm": True}},
)
content = result.structured_content
assert content["deleted"] is False
assert "permission" in (content["error"] or "").lower()
assert content["dashboard"]["id"] == 1