Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions superset/mcp_service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def get_default_instructions(
- get_dashboard_info: Get detailed dashboard information by ID
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
- duplicate_dashboard: Duplicate an existing dashboard, optionally deep-copying its charts (requires write access)
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)

Annotation Layers:
Expand Down Expand Up @@ -413,8 +414,9 @@ def get_default_instructions(
{_feature_availability}Permission Awareness:
{_instance_info_role_bullet}- ALWAYS check the user's roles BEFORE suggesting write operations (creating datasets,
charts, or dashboards). SQL execution is a separate permission — see execute_sql below.
- Write tools (generate_chart, generate_dashboard, update_chart, create_virtual_dataset,
save_sql_query, add_chart_to_existing_dashboard, update_chart_preview) require write
- Write tools (generate_chart, generate_dashboard, duplicate_dashboard, update_chart,
create_virtual_dataset, save_sql_query, add_chart_to_existing_dashboard,
update_chart_preview) require write
permissions. These tools are only listed for users who have the necessary access.
If a write tool does not appear in the tool list, the current user lacks write access.
- execute_sql requires SQL Lab access (execute_sql_query permission), which is separate
Expand Down Expand Up @@ -679,6 +681,7 @@ def create_mcp_app(
)
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
duplicate_dashboard,
generate_dashboard,
get_dashboard_info,
get_dashboard_layout,
Expand Down
132 changes: 132 additions & 0 deletions superset/mcp_service/dashboard/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,138 @@ class GenerateDashboardResponse(BaseModel):
)


class DuplicateDashboardRequest(BaseModel):
"""Request schema for duplicating an existing dashboard."""

model_config = ConfigDict(populate_by_name=True)

dashboard_id: Annotated[
int | str,
Field(
description=(
"Source dashboard identifier - can be numeric ID, UUID string, or slug"
)
),
]
dashboard_title: str = Field(
...,
description="Title for the new (duplicated) dashboard",
validation_alias=AliasChoices("dashboard_title", "title", "name"),
)
duplicate_slices: bool = Field(
default=False,
description=(
"When true, every chart on the source dashboard is deep-copied "
"into a new chart object owned by the caller. When false "
"(default), the new dashboard references the same charts as the "
"source."
),
)
sanitization_warnings: List[str] = Field(
default_factory=list,
description=(
"Internal: warnings emitted when user input was altered by "
"sanitization. Populated by the ``mode='before'`` validator "
"before dashboard_title is rewritten, so the tool can surface "
"a notice to the caller instead of silently dropping content."
),
)

@model_validator(mode="before")
@classmethod
def _detect_dashboard_title_sanitization(cls, data: Any) -> Any:
"""Reject empty-after-sanitization titles and warn on partial strip.

Runs before the ``dashboard_title`` field validator rewrites the
value. If the caller supplied a title that sanitization would strip
entirely (XSS-only content), we raise so the caller gets a clear
error instead of a blank-titled dashboard. When the sanitizer only
trims part of the title, we record a warning the tool can return
alongside the successful result.

``sanitization_warnings`` is a server-only field — any value the
caller supplied is discarded here so the tool cannot be tricked
into echoing attacker-controlled text back through the response.
"""
if not isinstance(data, dict):
return data
data["sanitization_warnings"] = []
for key in ("dashboard_title", "title", "name"):
if key in data:
raw = data[key]
break
else:
raw = None
if not isinstance(raw, str) or not raw.strip():
return data
sanitized, was_modified = sanitize_user_input_with_changes(
raw, "Dashboard title", max_length=500, allow_empty=True
)
if was_modified and not sanitized:
raise ValueError(
"dashboard_title contained only disallowed content "
"(HTML/script/URL schemes) and was removed entirely by "
"sanitization. Provide a dashboard_title with plain text."
)
if was_modified:
data["sanitization_warnings"].append(
"dashboard_title was modified during sanitization to "
"remove potentially unsafe content; the stored title "
"differs from the input."
)
return data

@field_validator("dashboard_title")
@classmethod
def sanitize_dashboard_title(cls, v: str) -> str:
"""Sanitize dashboard title to prevent XSS."""
sanitized = sanitize_user_input(
v, "Dashboard title", max_length=500, allow_empty=True
)
if not sanitized:
raise ValueError("dashboard_title cannot be empty")
return sanitized


class DuplicateDashboardResponse(BaseModel):
"""Response schema for dashboard duplication."""

dashboard: DashboardInfo | None = Field(
None, description="The newly created dashboard info, if successful"
)
dashboard_url: str | None = Field(None, description="URL to view the new dashboard")
duplicated_slices: bool = Field(
default=False,
description=(
"True when the source dashboard's charts were deep-copied into "
"new chart objects; False when the new dashboard references the "
"original charts."
),
)
error: str | None = Field(None, description="Error message, if duplication failed")
warnings: List[str] = Field(
default_factory=list,
description=(
"Non-fatal advisory messages about the duplicated dashboard — "
"for example, that the supplied title was altered by "
"sanitization."
),
)

@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 dashboard-controlled content such as the source
dashboard title — wrap it 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 ChartPosition(BaseModel):
"""Position and identity of a chart within a dashboard layout."""

Expand Down
2 changes: 2 additions & 0 deletions superset/mcp_service/dashboard/tool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# under the License.

from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .duplicate_dashboard import duplicate_dashboard
from .generate_dashboard import generate_dashboard
from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
Expand All @@ -26,5 +27,6 @@
"get_dashboard_info",
"get_dashboard_layout",
"generate_dashboard",
"duplicate_dashboard",
"add_chart_to_existing_dashboard",
]
Loading
Loading