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
5 changed files with 354 additions and 0 deletions

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

@@ -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