feat(mcp): add structured dashboard text component management (#44560)

This commit is contained in:
Amin Ghadersohi
2026-10-07 07:48:40 +11:00
committed by GitHub
parent 17f4c215b3
commit 5a2f4c8e80
18 changed files with 2599 additions and 310 deletions

View File

@@ -355,6 +355,53 @@ JSON, impact reporting still checks its saved form data and other accessible cha
| `delete_dashboard` | Delete a dashboard by ID, UUID, or slug; leaves its charts intact (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the dashboard — owners, Admins, and explicitly granted editors qualify) |
| `restore_dashboard` | Restore a soft-deleted dashboard from trash by ID or UUID (slug lookup does not cover trashed dashboards); not gated on `SOFT_DELETE`, so rows trashed while it was enabled stay recoverable, but only soft-deleted dashboards can be restored — permanently deleted dashboards are unrecoverable; returns `permission_denied` if the caller can see the dashboard but isn't an editor of it, or `NotFound` if the dashboard is outside the caller's RBAC visibility entirely |
### Dashboard text components
Use `manage_dashboard_markdown` to add, update, or remove markdown tiles,
section headers, and dividers without replacing the dashboard's raw layout.
The caller needs Dashboard write permission and editorship of the dashboard.
```json
{
"request": {
"dashboard_id": 42,
"add": [
{"component_type": "header", "text": "Sales overview"},
{"component_type": "markdown", "code": "**Updated daily**", "width": 12},
{"component_type": "divider"}
]
}
}
```
Markdown tiles are appended in their own rows; headers and dividers are appended
directly to the grid or tab. Each addition can specify `target_tab` by name or
component ID (available from `get_dashboard_layout`), including nested tabs.
Without it, additions use the first top-level tab, or the grid on dashboards
without top-level tabs. Markdown width is 1–12 columns (default 4); height is in
8-pixel grid units (default 50). Width updates
must fit the space remaining beside other components in the same 12-column row;
otherwise, the error reports the available width. Components removed in the same
request do not count toward that row width. If any operation is rejected, the
layout remains unchanged.
The response includes generated `added_component_ids` and a `components` summary.
Reuse those IDs in `update`, for example
`{"id": "MARKDOWN-…", "code": "Revised notes"}`, or in `remove`, for example
`["MARKDOWN-…"]`. Updates merge only supplied non-null fields and cannot change a
component's type or placement. Each update requires at least one non-null field
besides `id`; ID-only or all-null updates are rejected. The reserved dashboard-title `HEADER_ID` is not a
section header and cannot be changed through this tool.
Operations in one request are validated before saving. Existing charts, native
filters, and unrelated dashboard properties are preserved. Removing a tile prunes
empty row/column wrappers but retains tabs. Invalid component IDs, incompatible
fields, invalid layouts, and conflicting update/removal operations return errors
without saving partial changes. Markdown uses the dashboard's existing renderer
and HTML sanitization configuration. Concurrent layout edits are last-write-wins;
avoid simultaneous edits from the UI and MCP clients. This tool does not reorder
or move existing components; use the dashboard editor for those operations.
### SQL
| Tool | Description |

View File

@@ -135,6 +135,7 @@ Dashboard Management:
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
- delete_dashboard: Delete a dashboard by ID/UUID/slug (requires editor rights — owner or Admin; destructive; does not delete its charts; soft-deletes to trash when the SOFT_DELETE feature flag is on, permanent otherwise)
- manage_native_filters: Add, update, remove, or reorder native filters on a dashboard (requires write access; supports filter_select, filter_time, filter_range, and filter_timegrain)
- manage_dashboard_markdown: Add, update, or remove markdown/header/divider layout components on a dashboard (requires write access)
- apply_dashboard_filters: Apply values to a dashboard's existing native filters for the calling user and return a shareable permalink (read access; does NOT change the saved dashboard)
- remove_chart_from_dashboard: Remove a chart from an existing dashboard (requires write access)
- restore_dashboard: Restore a soft-deleted dashboard from trash by ID/UUID (requires editor rights — owner or Admin; only applies to dashboards trashed under the SOFT_DELETE feature flag)
@@ -531,7 +532,8 @@ Input format:
create_dataset_metric, delete_dataset_metric, update_dataset_metric,
save_sql_query, add_chart_to_existing_dashboard, manage_native_filters,
remove_chart_from_dashboard, update_chart_preview, manage_dashboard_owners,
manage_dashboard_roles, manage_dashboard_certification) require write
manage_dashboard_roles, manage_dashboard_certification,
manage_dashboard_markdown) 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
@@ -839,6 +841,7 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
get_dashboard_layout,
list_dashboards,
manage_dashboard_certification,
manage_dashboard_markdown,
manage_dashboard_owners,
manage_dashboard_roles,
manage_native_filters,

View File

@@ -26,6 +26,10 @@ import uuid
GRID_DEFAULT_CHART_WIDTH = 4
GRID_COLUMN_COUNT = 12
GRID_MIN_ROW_UNITS = 5
GRID_MAX_ROW_UNITS = 100
GRID_ID = "GRID_ID"
HEADER_ID = "HEADER_ID"
def generate_id(prefix: str) -> str:

View File

@@ -0,0 +1,309 @@
# 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.
"""
Shared layout-placement helpers for MCP tools that insert new components
into a dashboard's ``position_json`` tree.
Extracted from ``add_chart_to_existing_dashboard`` so that other
layout-mutating tools (e.g. ``manage_dashboard_markdown``) can resolve the
same GRID/TABS insertion target and scaffold the same GRID/ROOT structure
without duplicating the tab-matching logic.
"""
import re
from collections import deque
from typing import Any, Dict
from superset.mcp_service.dashboard.constants import generate_id
# Compiled regex for stripping common emoji Unicode ranges from tab text.
# Uses specific Unicode blocks to avoid overly permissive ranges.
_EMOJI_RE = re.compile(
"["
"\U0001f300-\U0001f5ff" # Misc Symbols and Pictographs
"\U0001f600-\U0001f64f" # Emoticons
"\U0001f680-\U0001f6ff" # Transport and Map Symbols
"\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs
"\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A
"☀-⛿" # Misc Symbols
"✀-➿" # Dingbats
"︀-️" # Variation Selectors
"‍" # Zero-width joiner
"]+"
)
# Container types that should be deleted once they have no children left.
# TAB/TABS/GRID/ROOT containers are intentionally kept even when empty —
# deleting a TAB would silently change the dashboard's visible structure.
_PRUNABLE_TYPES = ("ROW", "COLUMN")
def find_parent_key(layout: Dict[str, Any], component_key: str) -> str | None:
"""Find the component whose children list contains *component_key*.
The reverse lookup scans children lists instead of trusting the
``parents`` metadata on the node, which can be stale in hand-edited or
programmatically generated layouts.
"""
for key, node in layout.items():
if not isinstance(node, dict):
continue
children = node.get("children")
if isinstance(children, list) and component_key in children:
return key
return None
def remove_component_and_prune(layout: Dict[str, Any], component_key: str) -> list[str]:
"""Remove *component_key* from the layout and prune empty containers.
Walks up the parent chain deleting ROW/COLUMN containers that become
empty as a result of the removal, so no orphaned wrapper nodes are left
behind. Returns the list of removed layout keys.
"""
removed: list[str] = []
parent_key = find_parent_key(layout, component_key)
layout.pop(component_key, None)
removed.append(component_key)
child_key = component_key
while parent_key is not None:
parent = layout.get(parent_key)
if not isinstance(parent, dict):
break
children = parent.get("children")
if isinstance(children, list):
parent["children"] = [c for c in children if c != child_key]
if parent.get("type") in _PRUNABLE_TYPES and not parent.get("children"):
grandparent_key = find_parent_key(layout, parent_key)
layout.pop(parent_key, None)
removed.append(parent_key)
child_key = parent_key
parent_key = grandparent_key
else:
break
return removed
def find_next_row_position(layout: Dict[str, Any]) -> str:
"""
Generate a unique ROW ID for a new row in the dashboard layout.
Uses UUID-based IDs (e.g. ``ROW-a1b2c3d4``) instead of numeric indices
so that the IDs are compatible with real dashboard layouts that use
nanoid-style identifiers.
Returns:
A new unique ROW ID string.
"""
row_key = generate_id("ROW")
# Ensure uniqueness (extremely unlikely collision, but safe)
while row_key in layout:
row_key = generate_id("ROW")
return row_key
def _normalize_tab_text(text: str | None) -> str:
"""Strip emoji and extra whitespace from tab text for flexible matching."""
if not text:
return ""
cleaned = _EMOJI_RE.sub("", text)
return cleaned.strip().lower()
def _match_tab_in_children(
layout: Dict[str, Any],
tabs_children: list[str],
target_tab: str,
) -> str | None:
"""Search tabs_children for a tab matching target_tab by ID or name.
Matching is flexible: exact ID match, exact text match, or
case-insensitive text match after stripping emoji characters.
"""
target_normalized = _normalize_tab_text(target_tab)
for tab_id in tabs_children:
tab = layout.get(tab_id)
if not tab or tab.get("type") != "TAB":
continue
tab_text = (tab.get("meta") or {}).get("text", "")
# Exact match on ID or text
if target_tab in (tab_id, tab_text):
return tab_id
# Flexible match: case-insensitive, emoji-stripped
if target_normalized and _normalize_tab_text(tab_text) == target_normalized:
return tab_id
return None
def _collect_tabs_groups(
layout: Dict[str, Any], *, include_nested: bool = True
) -> list[list[str]]:
"""Collect reachable TABS groups, with ROOT/GRID groups taking precedence.
Traverse children rather than trusting parent metadata. The non-recursive
mode preserves the default insertion target for callers without target_tab.
"""
groups: list[list[str]] = []
pending = deque(["ROOT_ID", "GRID_ID"])
visited: set[str] = set()
collected: set[str] = set()
while pending:
parent_key = pending.popleft()
if parent_key in visited:
continue
visited.add(parent_key)
parent = layout.get(parent_key)
if not isinstance(parent, dict):
continue
for child_id in parent.get("children", []):
child = layout.get(child_id)
if not isinstance(child, dict):
continue
if include_nested:
pending.append(child_id)
if child.get("type") == "TABS" and child_id not in collected:
collected.add(child_id)
tabs_children = child.get("children", [])
if tabs_children:
groups.append(tabs_children)
return groups
def _first_tab_from_groups(
layout: Dict[str, Any], groups: list[list[str]]
) -> str | None:
"""Return the first valid TAB ID from the collected groups."""
for tabs_children in groups:
first_tab_id = tabs_children[0]
first_tab = layout.get(first_tab_id)
if first_tab and first_tab.get("type") == "TAB":
return first_tab_id
return None
def collect_available_tab_names(layout: Dict[str, Any]) -> list[str]:
"""Collect display entries (label + component ID) for all TAB components.
Always includes the component ID so callers can retry unambiguously even
when multiple tabs share the same display name or a label is blank.
"""
entries: list[str] = []
for tabs_children in _collect_tabs_groups(layout):
for tab_id in tabs_children:
tab = layout.get(tab_id)
if not tab or tab.get("type") != "TAB":
continue
text = (tab.get("meta") or {}).get("text", "")
entries.append(f"{text} ({tab_id})" if text else tab_id)
return entries
def find_tab_insert_target(
layout: Dict[str, Any], target_tab: str | None = None
) -> str | None:
"""
Detect if the dashboard uses tabs and return the appropriate tab's ID.
When *target_tab* is ``None`` the function returns the first TAB child so
that new rows are placed inside the tab structure rather than directly
under ``GRID_ID``.
When *target_tab* is provided the function tries to match it against tab
``meta.text`` (display name) or the raw component ID. If no match is
found ``None`` is returned — the caller is responsible for surfacing an
error rather than silently placing the chart in the wrong tab.
Returns:
The ID of the matched (or first) TAB component, or ``None``.
"""
groups = _collect_tabs_groups(layout, include_nested=target_tab is not None)
if target_tab is not None:
for tabs_children in groups:
matched = _match_tab_in_children(layout, tabs_children, target_tab)
if matched:
return matched
# target_tab specified but not found — signal mismatch to the caller.
return None
return _first_tab_from_groups(layout, groups)
def ensure_layout_structure(
layout: Dict[str, Any], component_key: str, parent_id: str
) -> None:
"""
Ensure the dashboard layout has proper GRID and ROOT structure,
and add the new component to the correct parent container.
Args:
layout: The mutable layout dict to update.
component_key: The top-level component ID to insert (a ROW, or a
GRID/TAB-level component such as HEADER or DIVIDER).
parent_id: The container to add the component to (GRID_ID or a TAB ID).
"""
# Ensure GRID structure exists
if "GRID_ID" not in layout:
layout["GRID_ID"] = {
"children": [],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
}
# Add the component to the target parent container
if parent := layout.get(parent_id):
if "children" not in parent:
parent["children"] = []
parent["children"].append(component_key)
else:
# Fallback: add to GRID_ID
if "children" not in layout["GRID_ID"]:
layout["GRID_ID"]["children"] = []
layout["GRID_ID"]["children"].append(component_key)
# Update ROOT_ID if it exists, or create it
if "ROOT_ID" in layout:
if "children" not in layout["ROOT_ID"]:
layout["ROOT_ID"]["children"] = []
# Only add GRID_ID to ROOT_ID when TABS are not already a direct
# child of ROOT_ID. Real Superset dashboards with tabs place a
# TABS container directly under ROOT_ID (ROOT_ID → TABS → TABs).
# Adding GRID_ID as a sibling of TABS confuses the frontend layout
# engine and makes charts invisible.
root_children = layout["ROOT_ID"]["children"]
has_tabs_under_root = any(
layout.get(c, {}).get("type") == "TABS" for c in root_children
)
if not has_tabs_under_root and "GRID_ID" not in root_children:
root_children.append("GRID_ID")
else:
# Create ROOT_ID if it doesn't exist
layout["ROOT_ID"] = {
"children": ["GRID_ID"],
"id": "ROOT_ID",
"type": "ROOT",
}
# Ensure dashboard version
if "DASHBOARD_VERSION_KEY" not in layout:
layout["DASHBOARD_VERSION_KEY"] = "v2"

View File

@@ -224,7 +224,7 @@ def _find_cycle(components: dict[str, dict[str, Any]]) -> str | None:
def validate_dashboard_layout( # noqa: C901
layout: dict[str, Any], expected_chart_ids: Collection[int]
layout: dict[str, Any], expected_chart_ids: Collection[int] | None
) -> str | None:
"""Return an error when an MCP layout replacement is unsafe to persist.
@@ -236,6 +236,12 @@ def validate_dashboard_layout( # noqa: C901
``HEADER_ID`` is dashboard metadata rather than a rendered tree child.
Superset also retains an empty, detached ``GRID_ID`` when top-level tabs are
used; both are allowed as explicit reserved-node exceptions.
Pass ``expected_chart_ids=None`` to run only the structural checks. Edits
that never touch ``CHART`` nodes use this, because a dashboard may
legitimately have associated charts that are not yet in its layout (e.g.
after "Save & add to dashboard" from Explore; the frontend appends those
during hydration).
"""
components, error = _validate_component_shapes(layout)
if error:
@@ -303,6 +309,9 @@ def validate_dashboard_layout( # noqa: C901
continue
return f"Layout component {component_id} is unreachable from ROOT_ID."
if expected_chart_ids is None:
return None
expected = set(expected_chart_ids)
if missing := sorted(expected - reachable_chart_ids):
return f"Layout would hide dashboard charts: {missing}."

View File

@@ -107,6 +107,12 @@ from superset.mcp_service.common.pagination_schemas import (
PaginatedResponse,
)
from superset.mcp_service.common.time_range_validation import validate_time_range
from superset.mcp_service.dashboard.constants import (
GRID_COLUMN_COUNT,
GRID_DEFAULT_CHART_WIDTH,
GRID_MAX_ROW_UNITS,
GRID_MIN_ROW_UNITS,
)
from superset.mcp_service.privacy import (
filter_user_directory_fields,
strip_user_directory_fields_from_schema,
@@ -122,6 +128,7 @@ from superset.mcp_service.utils.response_utils import (
OmittedFieldsBuilder,
)
from superset.mcp_service.utils.sanitization import (
_remove_dangerous_unicode,
sanitize_user_input,
sanitize_user_input_with_changes,
)
@@ -2595,6 +2602,268 @@ class ManageNativeFiltersResponse(BaseModel):
)
# ---------------------------------------------------------------------------
# manage_dashboard_markdown schemas
# ---------------------------------------------------------------------------
class BaseNewDashboardComponentSpec(BaseModel):
"""Common placement fields shared by all new markdown/header/divider specs."""
model_config = ConfigDict(extra="forbid")
target_tab: str | None = Field(
None,
description=(
"Tab to add the component to, matched by display name or "
"component ID (see get_dashboard_layout for available tabs). "
"Omit to use the first tab, or the grid if there are no tabs; "
"specify a target when the component should land in a "
"specific one rather than the first tab."
),
)
class MarkdownComponentSpec(BaseNewDashboardComponentSpec):
"""Spec for a new markdown/text tile.
Placed in its own new row (a MARKDOWN component sits alongside charts,
not as a full-width band), so it composes with existing rows/charts on
the target grid or tab.
"""
component_type: Literal["markdown"] = Field(
..., description="Discriminator - must be 'markdown'"
)
code: str = Field(
..., min_length=1, description="Markdown (and safe inline HTML) source"
)
width: int = Field(
GRID_DEFAULT_CHART_WIDTH,
ge=1,
le=GRID_COLUMN_COUNT,
description=(
f"Tile width in grid columns (1-{GRID_COLUMN_COUNT}, "
f"default {GRID_DEFAULT_CHART_WIDTH})"
),
)
height: int = Field(
50,
ge=GRID_MIN_ROW_UNITS,
le=GRID_MAX_ROW_UNITS,
description=(
f"Tile height in grid units ({GRID_MIN_ROW_UNITS}-"
f"{GRID_MAX_ROW_UNITS}; one unit is "
"8 pixels; default 50)"
),
)
_HEADER_TEXT_MAX_LENGTH = 500
def _sanitize_header_text(value: str) -> str:
"""Normalize header text, which the frontend renders as plain React text.
React escapes the value on render, so HTML stripping or entity escaping
here would only corrupt legitimate text such as ``Revenue < 1M`` (the
dashboard builder stores whatever the user types). Only length and
invisible/control characters are enforced.
"""
text = _remove_dangerous_unicode(value).strip()
if not text:
raise ValueError("text cannot be empty.")
if len(text) > _HEADER_TEXT_MAX_LENGTH:
raise ValueError(
f"text too long ({len(text)} characters). Maximum allowed length "
f"is {_HEADER_TEXT_MAX_LENGTH} characters."
)
return text
class HeaderComponentSpec(BaseNewDashboardComponentSpec):
"""Spec for a new section header band.
Placed directly on the target grid/tab (not inside a row) so it spans
the full dashboard width, matching how the dashboard builder places
dragged header components.
"""
component_type: Literal["header"] = Field(
..., description="Discriminator - must be 'header'"
)
text: str = Field(..., min_length=1, description="Header display text")
header_size: Literal["SMALL_HEADER", "MEDIUM_HEADER", "LARGE_HEADER"] = Field(
"MEDIUM_HEADER", description="Header text size"
)
background: Literal["BACKGROUND_TRANSPARENT", "BACKGROUND_WHITE"] = Field(
"BACKGROUND_TRANSPARENT", description="Header band background"
)
@field_validator("text")
@classmethod
def sanitize_text(cls, v: str) -> str:
"""Normalize header text; it renders as plain React text."""
return _sanitize_header_text(v)
class DividerComponentSpec(BaseNewDashboardComponentSpec):
"""Spec for a new horizontal divider.
Placed directly on the target grid/tab (not inside a row), same as
``HeaderComponentSpec``. Carries no content — only placement.
"""
component_type: Literal["divider"] = Field(
..., description="Discriminator - must be 'divider'"
)
NewDashboardComponentSpec = Annotated[
MarkdownComponentSpec | HeaderComponentSpec | DividerComponentSpec,
Field(discriminator="component_type"),
]
class DashboardComponentUpdateSpec(BaseModel):
"""Partial update for an existing markdown/header/divider component.
``id`` and at least one non-null update field are required. Provided
fields are merged into the existing component. Fields that only apply to
one component type (e.g. ``code`` for markdown, ``text``/``header_size``
for header) are rejected when used against the wrong component type.
A component's type cannot be changed; remove and re-add instead.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, description="ID of the component to update")
code: str | None = Field(
None, min_length=1, description="New markdown source (markdown only)"
)
width: int | None = Field(
None,
ge=1,
le=GRID_COLUMN_COUNT,
description="New tile width in grid columns (markdown only)",
)
height: int | None = Field(
None,
ge=GRID_MIN_ROW_UNITS,
le=GRID_MAX_ROW_UNITS,
description=(
f"New tile height in 8-pixel grid units ({GRID_MIN_ROW_UNITS}-"
f"{GRID_MAX_ROW_UNITS}; "
"markdown only)"
),
)
text: str | None = Field(None, description="New header text (header only)")
header_size: Literal["SMALL_HEADER", "MEDIUM_HEADER", "LARGE_HEADER"] | None = (
Field(None, description="New header text size (header only)")
)
background: Literal["BACKGROUND_TRANSPARENT", "BACKGROUND_WHITE"] | None = Field(
None, description="New header band background (header only)"
)
@field_validator("text")
@classmethod
def sanitize_text(cls, v: str | None) -> str | None:
"""Normalize header text; it renders as plain React text."""
return None if v is None else _sanitize_header_text(v)
@model_validator(mode="after")
def _require_update_field(self) -> "DashboardComponentUpdateSpec":
"""Reject updates that cannot change any component metadata."""
if not any(
getattr(self, field) is not None
for field in (
"code",
"width",
"height",
"text",
"header_size",
"background",
)
):
raise ValueError(
"At least one non-null update field besides id is required"
)
return self
class DashboardComponentSummary(BaseModel):
"""Summary of a markdown/header/divider component for LLM consumption."""
id: str = Field(description="Layout component ID")
component_type: Literal["markdown", "header", "divider"] = Field(
description="Component type"
)
meta: Dict[str, Any] = Field(
default_factory=dict,
description=(
"Component metadata (e.g. code/width/height for markdown; "
"text/headerSize/background for header; empty for divider)"
),
)
class ManageDashboardMarkdownRequest(BaseModel):
"""Request schema for the manage_dashboard_markdown tool."""
model_config = ConfigDict(extra="forbid")
dashboard_id: int = Field(
..., strict=True, gt=0, description="ID of the dashboard to modify"
)
add: List[NewDashboardComponentSpec] = Field(
default_factory=list,
description="New markdown/header/divider components to create.",
)
update: List[DashboardComponentUpdateSpec] = Field(
default_factory=list,
description="Partial updates to existing components, addressed by component ID",
)
remove: List[str] = Field(
default_factory=list,
description="IDs of markdown/header/divider components to delete",
)
@model_validator(mode="after")
def _require_at_least_one_operation(self) -> "ManageDashboardMarkdownRequest":
"""Reject requests with no component operations."""
if not self.add and not self.update and not self.remove:
raise ValueError("At least one operation (add, update, remove) is required")
return self
class ManageDashboardMarkdownResponse(DashboardMutationErrorFields):
"""Response schema for the manage_dashboard_markdown tool."""
dashboard_id: int | None = Field(None, description="ID of the dashboard")
dashboard_url: str | None = Field(
None, description="URL to view the updated dashboard"
)
added_component_ids: List[str] = Field(
default_factory=list,
description=(
"Server-generated IDs of the newly created components, in request order"
),
)
updated_component_ids: List[str] = Field(
default_factory=list, description="IDs of the components that were updated"
)
removed_component_ids: List[str] = Field(
default_factory=list, description="IDs of the components that were removed"
)
components: List[DashboardComponentSummary] = Field(
default_factory=list,
description=(
"All markdown/header/divider components on the dashboard after "
"the operation"
),
)
# ---------------------------------------------------------------------------
# apply_dashboard_filters schemas
# ---------------------------------------------------------------------------

View File

@@ -26,6 +26,7 @@ from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
from .list_dashboards import list_dashboards
from .manage_dashboard_certification import manage_dashboard_certification
from .manage_dashboard_markdown import manage_dashboard_markdown
from .manage_dashboard_owners import manage_dashboard_owners
from .manage_dashboard_roles import manage_dashboard_roles
from .manage_native_filters import manage_native_filters
@@ -44,6 +45,7 @@ __all__ = [
"add_chart_to_existing_dashboard",
"apply_dashboard_filters",
"manage_dashboard_certification",
"manage_dashboard_markdown",
"manage_dashboard_owners",
"manage_dashboard_roles",
"manage_native_filters",

View File

@@ -22,7 +22,6 @@ This tool adds a chart to an existing dashboard with automatic layout positionin
"""
import logging
import re
from typing import Any, Dict
from fastmcp import Context
@@ -36,6 +35,12 @@ from superset.mcp_service.dashboard.constants import (
GRID_COLUMN_COUNT,
GRID_DEFAULT_CHART_WIDTH,
)
from superset.mcp_service.dashboard.layout_placement import (
collect_available_tab_names,
ensure_layout_structure,
find_next_row_position,
find_tab_insert_target,
)
from superset.mcp_service.dashboard.layout_validation import rebuild_parent_chains
from superset.mcp_service.dashboard.schemas import (
AddChartToDashboardRequest,
@@ -49,154 +54,6 @@ from superset.utils import json
logger = logging.getLogger(__name__)
# Compiled regex for stripping common emoji Unicode ranges from tab text.
# Uses specific Unicode blocks to avoid overly permissive ranges.
_EMOJI_RE = re.compile(
"["
"\U0001f300-\U0001f5ff" # Misc Symbols and Pictographs
"\U0001f600-\U0001f64f" # Emoticons
"\U0001f680-\U0001f6ff" # Transport and Map Symbols
"\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs
"\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A
"\u2600-\u26ff" # Misc Symbols
"\u2700-\u27bf" # Dingbats
"\ufe00-\ufe0f" # Variation Selectors
"\u200d" # Zero-width joiner
"]+"
)
def _find_next_row_position(layout: Dict[str, Any]) -> str:
"""
Generate a unique ROW ID for a new row in the dashboard layout.
Uses UUID-based IDs (e.g. ``ROW-a1b2c3d4``) instead of numeric indices
so that the IDs are compatible with real dashboard layouts that use
nanoid-style identifiers.
Returns:
A new unique ROW ID string.
"""
row_key = generate_id("ROW")
# Ensure uniqueness (extremely unlikely collision, but safe)
while row_key in layout:
row_key = generate_id("ROW")
return row_key
def _normalize_tab_text(text: str | None) -> str:
"""Strip emoji and extra whitespace from tab text for flexible matching."""
if not text:
return ""
cleaned = _EMOJI_RE.sub("", text)
return cleaned.strip().lower()
def _match_tab_in_children(
layout: Dict[str, Any],
tabs_children: list[str],
target_tab: str,
) -> str | None:
"""Search tabs_children for a tab matching target_tab by ID or name.
Matching is flexible: exact ID match, exact text match, or
case-insensitive text match after stripping emoji characters.
"""
target_normalized = _normalize_tab_text(target_tab)
for tab_id in tabs_children:
tab = layout.get(tab_id)
if not tab or tab.get("type") != "TAB":
continue
tab_text = (tab.get("meta") or {}).get("text", "")
# Exact match on ID or text
if target_tab in (tab_id, tab_text):
return tab_id
# Flexible match: case-insensitive, emoji-stripped
if target_normalized and _normalize_tab_text(tab_text) == target_normalized:
return tab_id
return None
def _collect_tabs_groups(layout: Dict[str, Any]) -> list[list[str]]:
"""Collect all TABS groups from ROOT_ID and GRID_ID children.
Superset dashboards can place TABS under either ROOT_ID or GRID_ID
depending on how the layout was constructed.
"""
groups: list[list[str]] = []
for parent_key in ("ROOT_ID", "GRID_ID"):
parent = layout.get(parent_key)
if not parent:
continue
for child_id in parent.get("children", []):
child = layout.get(child_id)
if not child or child.get("type") != "TABS":
continue
tabs_children = child.get("children", [])
if tabs_children:
groups.append(tabs_children)
return groups
def _first_tab_from_groups(
layout: Dict[str, Any], groups: list[list[str]]
) -> str | None:
"""Return the first valid TAB ID from the collected groups."""
for tabs_children in groups:
first_tab_id = tabs_children[0]
first_tab = layout.get(first_tab_id)
if first_tab and first_tab.get("type") == "TAB":
return first_tab_id
return None
def _collect_available_tab_names(layout: Dict[str, Any]) -> list[str]:
"""Collect display entries (label + component ID) for all TAB components.
Always includes the component ID so callers can retry unambiguously even
when multiple tabs share the same display name or a label is blank.
"""
entries: list[str] = []
for tabs_children in _collect_tabs_groups(layout):
for tab_id in tabs_children:
tab = layout.get(tab_id)
if not tab or tab.get("type") != "TAB":
continue
text = (tab.get("meta") or {}).get("text", "")
entries.append(f"{text} ({tab_id})" if text else tab_id)
return entries
def _find_tab_insert_target(
layout: Dict[str, Any], target_tab: str | None = None
) -> str | None:
"""
Detect if the dashboard uses tabs and return the appropriate tab's ID.
When *target_tab* is ``None`` the function returns the first TAB child so
that new rows are placed inside the tab structure rather than directly
under ``GRID_ID``.
When *target_tab* is provided the function tries to match it against tab
``meta.text`` (display name) or the raw component ID. If no match is
found ``None`` is returned — the caller is responsible for surfacing an
error rather than silently placing the chart in the wrong tab.
Returns:
The ID of the matched (or first) TAB component, or ``None``.
"""
groups = _collect_tabs_groups(layout)
if target_tab is not None:
for tabs_children in groups:
matched = _match_tab_in_children(layout, tabs_children, target_tab)
if matched:
return matched
# target_tab specified but not found — signal mismatch to the caller.
return None
return _first_tab_from_groups(layout, groups)
def _add_chart_to_layout(
layout: Dict[str, Any],
@@ -268,66 +125,6 @@ def _add_chart_to_layout(
return chart_key, column_key, row_key
def _ensure_layout_structure(
layout: Dict[str, Any], row_key: str, parent_id: str
) -> None:
"""
Ensure the dashboard layout has proper GRID and ROOT structure,
and add the new row to the correct parent container.
Args:
layout: The mutable layout dict to update.
row_key: The ROW component ID to insert.
parent_id: The container to add the row to (GRID_ID or a TAB ID).
"""
# Ensure GRID structure exists
if "GRID_ID" not in layout:
layout["GRID_ID"] = {
"children": [],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
}
# Add row to the target parent container
if parent := layout.get(parent_id):
if "children" not in parent:
parent["children"] = []
parent["children"].append(row_key)
else:
# Fallback: add to GRID_ID
if "children" not in layout["GRID_ID"]:
layout["GRID_ID"]["children"] = []
layout["GRID_ID"]["children"].append(row_key)
# Update ROOT_ID if it exists, or create it
if "ROOT_ID" in layout:
if "children" not in layout["ROOT_ID"]:
layout["ROOT_ID"]["children"] = []
# Only add GRID_ID to ROOT_ID when TABS are not already a direct
# child of ROOT_ID. Real Superset dashboards with tabs place a
# TABS container directly under ROOT_ID (ROOT_ID → TABS → TABs).
# Adding GRID_ID as a sibling of TABS confuses the frontend layout
# engine and makes charts invisible.
root_children = layout["ROOT_ID"]["children"]
has_tabs_under_root = any(
layout.get(c, {}).get("type") == "TABS" for c in root_children
)
if not has_tabs_under_root and "GRID_ID" not in root_children:
root_children.append("GRID_ID")
else:
# Create ROOT_ID if it doesn't exist
layout["ROOT_ID"] = {
"children": ["GRID_ID"],
"id": "ROOT_ID",
"type": "ROOT",
}
# Ensure dashboard version
if "DASHBOARD_VERSION_KEY" not in layout:
layout["DASHBOARD_VERSION_KEY"] = "v2"
def _resolve_parent_container(
layout: Dict[str, Any],
dashboard_id: int,
@@ -338,10 +135,10 @@ def _resolve_parent_container(
When *target_tab* is specified and not found the caller receives a
descriptive error listing available tabs rather than a silent fallback.
"""
tab_target = _find_tab_insert_target(layout, target_tab=target_tab)
tab_target = find_tab_insert_target(layout, target_tab=target_tab)
if target_tab is not None and tab_target is None:
available = _collect_available_tab_names(layout)
available = collect_available_tab_names(layout)
if available:
tab_list = ", ".join(available)
return None, AddChartToDashboardResponse(
@@ -494,7 +291,7 @@ def add_chart_to_existing_dashboard( # noqa: C901 — complexity is structural
current_layout = {}
# Generate a unique ROW ID for the new row
row_key = _find_next_row_position(current_layout)
row_key = find_next_row_position(current_layout)
# Detect tabbed dashboards and resolve target_tab by name or ID.
parent_id, tab_error = _resolve_parent_container(
@@ -513,7 +310,7 @@ def add_chart_to_existing_dashboard( # noqa: C901 — complexity is structural
)
# Ensure proper layout structure
_ensure_layout_structure(current_layout, row_key, parent_id)
ensure_layout_structure(current_layout, row_key, parent_id)
# The new row/column/chart nodes were added with empty
# ``parents`` (see ``_add_chart_to_layout``); rebuild every

View File

@@ -0,0 +1,513 @@
# 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: manage_dashboard_markdown
Adds, updates, and removes markdown/header/divider layout components on a
dashboard by translating high-level operations into ``position_json`` tree
edits. Analogous to ``manage_native_filters``, which does the same for the
flat native-filter list in ``json_metadata`` — this tool exists so callers
don't have to hand-build the full raw layout tree just to add a text tile
or section header (see ``generate_dashboard``'s ``position_json`` docstring).
"""
import logging
from collections import Counter
from typing import Any, Dict, Iterable
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import db, event_logger
from superset.mcp_service.dashboard.constants import (
generate_id,
GRID_COLUMN_COUNT,
GRID_ID,
HEADER_ID,
)
from superset.mcp_service.dashboard.layout_placement import (
collect_available_tab_names,
ensure_layout_structure,
find_next_row_position,
find_parent_key,
find_tab_insert_target,
remove_component_and_prune,
)
from superset.mcp_service.dashboard.layout_validation import (
normalize_chart_id,
rebuild_parent_chains,
validate_dashboard_layout,
)
from superset.mcp_service.dashboard.schemas import (
DashboardComponentSummary,
DashboardComponentUpdateSpec,
HeaderComponentSpec,
ManageDashboardMarkdownRequest,
ManageDashboardMarkdownResponse,
MarkdownComponentSpec,
NewDashboardComponentSpec,
)
from superset.mcp_service.dashboard.tool.governance_utils import (
dashboard_url,
find_and_authorize_dashboard,
)
from superset.utils import json
logger = logging.getLogger(__name__)
def _layout_chart_ids(layout: Dict[str, Any]) -> set[int]:
"""Return the chart IDs referenced by CHART nodes in ``layout``."""
chart_ids: set[int] = set()
for component in layout.values():
if isinstance(component, dict) and component.get("type") == "CHART":
meta = component.get("meta")
if (
isinstance(meta, dict)
and (chart_id := normalize_chart_id(meta.get("chartId"))) is not None
):
chart_ids.add(chart_id)
return chart_ids
# Maps the tool-facing discriminator to the position_json component type.
_LAYOUT_TYPE_BY_COMPONENT_TYPE: dict[str, str] = {
"markdown": "MARKDOWN",
"header": "HEADER",
"divider": "DIVIDER",
}
_COMPONENT_TYPE_BY_LAYOUT_TYPE = {
v: k for k, v in _LAYOUT_TYPE_BY_COMPONENT_TYPE.items()
}
# HEADER and DIVIDER are full-width bands placed directly on GRID/TAB — ROW
# does not accept them as children (see layout_validation._PARENT_MAX_DEPTH).
# MARKDOWN composes with charts, so it is wrapped in its own new ROW instead.
_ROW_WRAPPED_LAYOUT_TYPES = frozenset({"MARKDOWN"})
class _ComponentOperationError(Exception):
"""Raised internally when a component operation fails validation."""
def _build_component_meta(spec: NewDashboardComponentSpec) -> Dict[str, Any]:
"""Build the position_json ``meta`` object for a new component spec."""
if isinstance(spec, MarkdownComponentSpec):
return {"code": spec.code, "width": spec.width, "height": spec.height}
if isinstance(spec, HeaderComponentSpec):
return {
"text": spec.text,
"headerSize": spec.header_size,
"background": spec.background,
}
# DividerComponentSpec carries no content, only placement.
return {}
def _resolve_target_container(layout: Dict[str, Any], target_tab: str | None) -> str:
"""Return the GRID_ID or TAB component ID a new component should attach to.
Raises ``_ComponentOperationError`` when *target_tab* is specified but
does not match any tab, listing the available tabs (or noting there are
none) so the caller can retry unambiguously.
"""
tab_target = find_tab_insert_target(layout, target_tab=target_tab)
if target_tab is not None and tab_target is None:
available = collect_available_tab_names(layout)
if available:
raise _ComponentOperationError(
f"Tab '{target_tab}' not found. Available tabs: {', '.join(available)}."
)
raise _ComponentOperationError(
"Dashboard has no tabs. Remove target_tab to add to the "
"default grid layout."
)
return tab_target if tab_target else GRID_ID
def _add_component_to_layout(
layout: Dict[str, Any], spec: NewDashboardComponentSpec
) -> str:
"""Insert a new markdown/header/divider component into *layout*.
Returns the new component's ID. New nodes are linked with empty
``parents`` — the caller rebuilds the whole tree's parent chains via
``rebuild_parent_chains`` after all operations are applied.
"""
parent_id = _resolve_target_container(layout, spec.target_tab)
layout_type = _LAYOUT_TYPE_BY_COMPONENT_TYPE[spec.component_type]
component_id = generate_id(layout_type)
while component_id in layout:
component_id = generate_id(layout_type)
layout[component_id] = {
"id": component_id,
"type": layout_type,
"children": [],
"meta": _build_component_meta(spec),
"parents": [],
}
if layout_type in _ROW_WRAPPED_LAYOUT_TYPES:
row_key = find_next_row_position(layout)
layout[row_key] = {
"id": row_key,
"type": "ROW",
"children": [component_id],
"meta": {"background": "BACKGROUND_TRANSPARENT"},
"parents": [],
}
ensure_layout_structure(layout, row_key, parent_id)
else:
ensure_layout_structure(layout, component_id, parent_id)
return component_id
def _apply_component_update( # noqa: C901
spec: DashboardComponentUpdateSpec, node: Dict[str, Any], component_type: str
) -> None:
"""Merge *spec* into *node*'s meta in place.
Raises ``_ComponentOperationError`` when a field that only applies to a
different component type is set.
"""
markdown_fields = ("code", "width", "height")
header_fields = ("text", "header_size", "background")
if component_type != "markdown":
if set_fields := [f for f in markdown_fields if getattr(spec, f) is not None]:
raise _ComponentOperationError(
f"Component '{spec.id}' has type '{component_type}'; fields "
f"{set_fields} only apply to markdown components."
)
if component_type != "header":
if set_fields := [f for f in header_fields if getattr(spec, f) is not None]:
raise _ComponentOperationError(
f"Component '{spec.id}' has type '{component_type}'; fields "
f"{set_fields} only apply to header components."
)
meta = dict(node.get("meta") or {})
if component_type == "markdown":
if spec.code is not None:
meta["code"] = spec.code
if spec.width is not None:
meta["width"] = spec.width
if spec.height is not None:
meta["height"] = spec.height
elif component_type == "header":
if spec.text is not None:
meta["text"] = spec.text
if spec.header_size is not None:
meta["headerSize"] = spec.header_size
if spec.background is not None:
meta["background"] = spec.background
node["meta"] = meta
def _validate_markdown_width(
layout: Dict[str, Any], component_id: str, width: int
) -> None:
"""Reject a resize that exceeds the space left by the row's siblings."""
parent_id = find_parent_key(layout, component_id)
parent = layout.get(parent_id) if parent_id is not None else None
if not parent or parent.get("type") != "ROW":
return
sibling_width = sum(
(layout[child_id].get("meta") or {}).get("width", 0)
for child_id in parent.get("children", [])
if child_id != component_id
)
if width > (available_width := max(0, GRID_COLUMN_COUNT - sibling_width)):
raise _ComponentOperationError(
f"Cannot resize component '{component_id}' to width {width}: "
f"available width in row '{parent_id}' is {available_width} columns."
)
def _duplicate_ids(ids: Iterable[str]) -> list[str]:
"""Return the sorted IDs that appear more than once in ``ids``."""
return sorted(cid for cid, count in Counter(ids).items() if count > 1)
def _apply_updates(
layout: Dict[str, Any],
updates: list[DashboardComponentUpdateSpec],
existing_components: Dict[str, Dict[str, Any]],
) -> list[str]:
"""Apply every update spec in order; returns the updated component IDs."""
if duplicates := _duplicate_ids(spec.id for spec in updates):
raise _ComponentOperationError(
f"update contains duplicate component IDs: {duplicates}."
)
for spec in updates:
node = existing_components.get(spec.id)
if node is None:
raise _ComponentOperationError(
f"Cannot update component '{spec.id}': not a markdown/header/"
"divider component on this dashboard."
)
component_type = _COMPONENT_TYPE_BY_LAYOUT_TYPE[node["type"]]
if component_type == "markdown" and spec.width is not None:
_validate_markdown_width(layout, spec.id, spec.width)
_apply_component_update(spec, layout[spec.id], component_type)
return [spec.id for spec in updates]
def _manageable_components(layout: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""Return the markdown/header/divider nodes of *layout* keyed by component ID."""
return {
key: node
for key, node in layout.items()
if key != HEADER_ID
and isinstance(node, dict)
and node.get("type") in _COMPONENT_TYPE_BY_LAYOUT_TYPE
}
def _component_summaries(layout: Dict[str, Any]) -> list[DashboardComponentSummary]:
"""Summarize every markdown/header/divider component currently in *layout*."""
return [
DashboardComponentSummary(
id=key,
component_type=_COMPONENT_TYPE_BY_LAYOUT_TYPE[node["type"]],
meta=node.get("meta") or {},
)
for key, node in _manageable_components(layout).items()
]
@tool(
tags=["mutate"],
class_permission_name="Dashboard",
method_permission_name="write",
annotations=ToolAnnotations(
title="Manage dashboard markdown/header/divider components",
readOnlyHint=False,
destructiveHint=True,
idempotentHint=False,
openWorldHint=False,
),
)
def manage_dashboard_markdown( # noqa: C901
request: ManageDashboardMarkdownRequest, ctx: Context
) -> ManageDashboardMarkdownResponse:
"""
Add, update, and remove markdown/header/divider layout components.
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
placed in their own new row so they compose with existing chart rows;
headers and dividers are placed as full-width bands directly on the
target grid or tab (matching how the dashboard builder places them).
Component IDs are server-generated and returned in the response.
``target_tab`` (add only) selects which tab a component lands in by
display name or ID; omit it for the first tab, or the grid without tabs.
Example::
manage_dashboard_markdown(request={
"dashboard_id": 42,
"add": [
{"component_type": "header", "text": "Sales"},
{"component_type": "markdown", "code": "**Updated daily**"},
],
})
"""
logger.info(
"Managing dashboard markdown components: dashboard_id=%s", request.dashboard_id
)
dashboard, auth_error = find_and_authorize_dashboard(
request.dashboard_id, ManageDashboardMarkdownResponse
)
if auth_error is not None:
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:
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."
),
)
try:
with event_logger.log_context(
action="mcp.manage_dashboard_markdown.validation"
):
try:
current_layout = json.loads(dashboard.position_json or "{}")
except (json.JSONDecodeError, TypeError):
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error=(
f"Dashboard {request.dashboard_id} has a malformed "
"layout (position_json could not be parsed); cannot "
"safely modify it."
),
)
if not isinstance(current_layout, dict):
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error="Dashboard has a malformed layout: expected a JSON object.",
)
# Validate before traversing or pruning; do not repair corrupt trees
# by silently dropping nodes. Empty dashboards can be scaffolded.
# Chart coverage is not enforced: this tool never touches CHART
# nodes, and associated charts missing from the layout (attached
# from Explore) are a normal state the frontend hydrates.
if current_layout:
if error := validate_dashboard_layout(current_layout, None):
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error=f"Dashboard has a malformed layout: {error}",
)
existing_components = _manageable_components(current_layout)
original_chart_ids = _layout_chart_ids(current_layout)
if unknown_removals := [
cid for cid in request.remove if cid not in existing_components
]:
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error=(
"Cannot remove components that are not markdown/"
f"header/divider components on this dashboard: "
f"{unknown_removals}."
),
)
if duplicate_removals := _duplicate_ids(request.remove):
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error=(
"remove contains duplicate component IDs: "
f"{duplicate_removals}."
),
)
removed_ids = set(request.remove)
if conflicts := sorted(
{spec.id for spec in request.update if spec.id in removed_ids}
):
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error=(
f"Components {conflicts} cannot be both updated and removed."
),
)
try:
# Validate resizes against siblings retained by this request.
for component_id in request.remove:
remove_component_and_prune(current_layout, component_id)
updated_ids = _apply_updates(
current_layout, request.update, existing_components
)
added_ids = [
_add_component_to_layout(current_layout, spec)
for spec in request.add
]
except _ComponentOperationError as exc:
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id, error=str(exc)
)
# New/updated nodes may carry empty or stale ``parents``; rebuild
# every reachable component's chain from the actual children
# edges so filter-scope derivation sees a correct tree. See
# superset.dashboards.filter_scope.get_chart_ids_in_scope.
current_layout = rebuild_parent_chains(current_layout)
# Chart nodes present before the edit must survive it unchanged.
if error := validate_dashboard_layout(current_layout, original_chart_ids):
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error=f"Resulting dashboard layout is invalid: {error}",
)
# Capture the receipt before committing. A failed refresh leaves ORM
# attributes expired, so reading them afterwards could mask a saved write.
result = ManageDashboardMarkdownResponse(
dashboard_id=dashboard.id,
dashboard_url=dashboard_url(dashboard),
added_component_ids=added_ids,
updated_component_ids=updated_ids,
removed_component_ids=list(request.remove),
components=_component_summaries(current_layout),
)
with event_logger.log_context(action="mcp.manage_dashboard_markdown.db_write"):
dashboard.position_json = json.dumps(current_layout)
db.session.commit() # pylint: disable=consider-using-transaction
try:
db.session.refresh(dashboard)
except SQLAlchemyError:
logger.warning(
"Dashboard %s updated but refresh failed; continuing",
request.dashboard_id,
exc_info=True,
)
except SQLAlchemyError as db_err:
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except SQLAlchemyError:
logger.warning(
"Database rollback failed during error handling", exc_info=True
)
logger.error("Dashboard markdown update failed: %s", db_err, exc_info=True)
return ManageDashboardMarkdownResponse(
dashboard_id=request.dashboard_id,
error="Failed to update dashboard due to a database error.",
)
except Exception as exc:
logger.exception(
"Unexpected error managing markdown components on dashboard %s: %s",
request.dashboard_id,
exc,
)
raise
logger.info(
"Managed dashboard markdown components on dashboard %s "
"(added=%d updated=%d removed=%d)",
request.dashboard_id,
len(added_ids),
len(updated_ids),
len(request.remove),
)
return result

View File

@@ -35,6 +35,9 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.exceptions import CommandException, ForbiddenError
from superset.extensions import event_logger
from superset.mcp_service.dashboard.layout_placement import (
remove_component_and_prune,
)
from superset.mcp_service.dashboard.layout_validation import (
normalize_chart_id,
rebuild_parent_chains,
@@ -51,11 +54,6 @@ from superset.utils import json
logger = logging.getLogger(__name__)
# Container types that should be deleted once they have no children left.
# TAB/TABS/GRID/ROOT containers are intentionally kept even when empty —
# deleting a TAB would silently change the dashboard's visible structure.
_PRUNABLE_TYPES = ("ROW", "COLUMN")
def _find_chart_keys(layout: Dict[str, Any], chart_id: int) -> list[str]:
"""Return all layout keys of CHART components referencing *chart_id*.
@@ -72,57 +70,6 @@ def _find_chart_keys(layout: Dict[str, Any], chart_id: int) -> list[str]:
]
def _find_parent_key(layout: Dict[str, Any], component_key: str) -> str | None:
"""Find the component whose children list contains *component_key*.
The reverse lookup scans children lists instead of trusting the
``parents`` metadata on the node, which can be stale in hand-edited or
programmatically generated layouts.
"""
for key, node in layout.items():
if not isinstance(node, dict):
continue
children = node.get("children")
if isinstance(children, list) and component_key in children:
return key
return None
def _remove_component_and_prune(
layout: Dict[str, Any], component_key: str
) -> list[str]:
"""Remove *component_key* from the layout and prune empty containers.
Walks up the parent chain deleting ROW/COLUMN containers that become
empty as a result of the removal, so no orphaned wrapper nodes are left
behind. Returns the list of removed layout keys.
"""
removed: list[str] = []
parent_key = _find_parent_key(layout, component_key)
layout.pop(component_key, None)
removed.append(component_key)
child_key = component_key
while parent_key is not None:
parent = layout.get(parent_key)
if not isinstance(parent, dict):
break
children = parent.get("children")
if isinstance(children, list):
parent["children"] = [c for c in children if c != child_key]
if parent.get("type") in _PRUNABLE_TYPES and not parent.get("children"):
grandparent_key = _find_parent_key(layout, parent_key)
layout.pop(parent_key, None)
removed.append(parent_key)
child_key = parent_key
parent_key = grandparent_key
else:
break
return removed
def _remove_chart_from_layout(layout: Dict[str, Any], chart_id: int) -> list[str]:
"""Remove every CHART component for *chart_id* from the layout.
@@ -132,7 +79,7 @@ def _remove_chart_from_layout(layout: Dict[str, Any], chart_id: int) -> list[str
for chart_key in _find_chart_keys(layout, chart_id):
# The chart key may already be gone if it shared a pruned container.
if chart_key in layout:
removed.extend(_remove_component_and_prune(layout, chart_key))
removed.extend(remove_component_and_prune(layout, chart_key))
return removed

View File

@@ -1298,6 +1298,7 @@ SCOPE_NEUTRAL_TOOLS = frozenset(
"list_themes",
"list_users",
"manage_dashboard_certification",
"manage_dashboard_markdown",
"manage_dashboard_owners",
"manage_dashboard_roles",
"manage_native_filters",

View File

@@ -406,6 +406,7 @@ MCP_CACHE_CONFIG: dict[str, Any] = {
"generate_dashboard",
"generate_explore_link",
"manage_dashboard_certification",
"manage_dashboard_markdown",
"manage_dashboard_owners",
"manage_dashboard_roles",
"manage_native_filters",

View File

@@ -652,6 +652,7 @@ COMMITTED_WRITE_SPECS: Dict[str, CommittedWriteSpec] = {
"generate_chart": _spec("chart", "chart", reports_success=True),
"generate_dashboard": _spec("dashboard", "dashboard"),
"manage_dashboard_certification": _spec("dashboard"),
"manage_dashboard_markdown": _spec("dashboard"),
"manage_dashboard_owners": _spec("dashboard"),
"manage_dashboard_roles": _spec("dashboard"),
"manage_native_filters": _spec("dashboard"),

View File

@@ -0,0 +1,101 @@
# 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.
"""Regression tests for emoji-insensitive dashboard tab matching."""
import sys
from typing import Any
import pytest
from superset.mcp_service.dashboard.layout_placement import (
_EMOJI_RE,
_normalize_tab_text,
collect_available_tab_names,
find_tab_insert_target,
)
def test_emoji_ranges_match_only_documented_code_points() -> None:
"""Check every Unicode code point, including gaps and U+FFFD."""
ranges: tuple[tuple[int, int], ...] = (
(0x1F300, 0x1F5FF),
(0x1F600, 0x1F64F),
(0x1F680, 0x1F6FF),
(0x1F900, 0x1F9FF),
(0x1FA70, 0x1FAFF),
(0x2600, 0x26FF),
(0x2700, 0x27BF),
(0xFE00, 0xFE0F),
)
expected: set[int] = {0x200D}
for start, end in ranges:
block: set[int] = set(range(start, end + 1))
assert expected.isdisjoint(block)
expected.update(block)
actual: set[int] = {
code_point
for code_point in range(sys.maxunicode + 1)
if _EMOJI_RE.fullmatch(chr(code_point))
}
assert actual == expected
@pytest.mark.parametrize(
("text", "expected"),
[
(" 📊 Sales 🚀 ", "sales"),
("😀 🤖 🪐 ☀ ✂ Revenue", "revenue"),
("👩\u200d💻 Forecast ☀\ufe0f", "forecast"),
(" Sales [A-Z] ^_` \\ ", "sales [a-z] ^_` \\"),
("Sales \ufffd", "sales \ufffd"),
("Équipe 東京", "équipe 東京"),
(None, ""),
("", ""),
],
)
def test_normalize_tab_text(text: str | None, expected: str) -> None:
"""Strip intended emoji while preserving ordinary labels and punctuation."""
assert _normalize_tab_text(text) == expected
@pytest.mark.parametrize("parent_type", ["TAB", "COLUMN"])
def test_nested_tab_matching_at_any_depth(parent_type: str) -> None:
"""Find nested tabs without changing default or top-level match priority."""
layout: dict[str, Any] = {
"ROOT_ID": {"children": ["GRID_ID"]},
"GRID_ID": {"children": ["container", "TABS-top"]},
"container": {"type": parent_type, "children": ["TABS-nested"]},
"TABS-top": {"type": "TABS", "children": ["TAB-top"]},
"TAB-top": {"type": "TAB", "meta": {"text": "Shared"}},
"TABS-nested": {"type": "TABS", "children": ["TAB-nested", "TAB-shared"]},
"TAB-nested": {"type": "TAB", "meta": {"text": "📊 Nested"}},
"TAB-shared": {"type": "TAB", "meta": {"text": "Shared"}},
}
assert find_tab_insert_target(layout) == "TAB-top"
assert find_tab_insert_target(layout, "Shared") == "TAB-top"
assert find_tab_insert_target(layout, "nested") == "TAB-nested"
assert find_tab_insert_target(layout, "TAB-nested") == "TAB-nested"
assert collect_available_tab_names(layout) == [
"Shared (TAB-top)",
"📊 Nested (TAB-nested)",
"Shared (TAB-shared)",
]
layout["GRID_ID"]["children"].remove("TABS-top")
assert find_tab_insert_target(layout) is None
assert find_tab_insert_target(layout, "nested") == "TAB-nested"

View File

@@ -30,12 +30,14 @@ from fastmcp import Client
from superset.mcp_service.app import mcp
from superset.mcp_service.chart.chart_utils import DatasetValidationResult
from superset.mcp_service.dashboard.constants import generate_id
from superset.mcp_service.dashboard.layout_placement import (
collect_available_tab_names,
ensure_layout_structure,
find_next_row_position,
find_tab_insert_target,
)
from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard import (
_add_chart_to_layout,
_collect_available_tab_names,
_ensure_layout_structure,
_find_next_row_position,
_find_tab_insert_target,
)
from superset.mcp_service.dashboard.tool.generate_dashboard import (
_generate_title_from_charts,
@@ -1893,28 +1895,28 @@ class TestLayoutHelpers:
assert len(ids) == 100
def test_find_next_row_position_empty_layout(self):
"""Test _find_next_row_position with empty layout."""
result = _find_next_row_position({})
"""Test find_next_row_position with empty layout."""
result = find_next_row_position({})
assert isinstance(result, str)
assert result.startswith("ROW-")
def test_find_tab_insert_target_no_tabs(self):
"""Test _find_tab_insert_target with no tabs."""
"""Test find_tab_insert_target with no tabs."""
layout = {"GRID_ID": {"children": ["ROW-1"], "type": "GRID"}}
assert _find_tab_insert_target(layout) is None
assert find_tab_insert_target(layout) is None
def test_find_tab_insert_target_with_tabs(self):
"""Test _find_tab_insert_target with tabbed dashboard."""
"""Test find_tab_insert_target with tabbed dashboard."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
"TAB-first": {"children": [], "type": "TAB"},
"TAB-second": {"children": [], "type": "TAB"},
}
assert _find_tab_insert_target(layout) == "TAB-first"
assert find_tab_insert_target(layout) == "TAB-first"
def test_find_tab_insert_target_by_tab_name(self):
"""Test _find_tab_insert_target resolves target_tab by display name."""
"""Test find_tab_insert_target resolves target_tab by display name."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
@@ -1929,10 +1931,10 @@ class TestLayoutHelpers:
"meta": {"text": "Customers"},
},
}
assert _find_tab_insert_target(layout, target_tab="Customers") == "TAB-second"
assert find_tab_insert_target(layout, target_tab="Customers") == "TAB-second"
def test_find_tab_insert_target_by_tab_id(self):
"""Test _find_tab_insert_target resolves target_tab by component ID."""
"""Test find_tab_insert_target resolves target_tab by component ID."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
@@ -1947,10 +1949,10 @@ class TestLayoutHelpers:
"meta": {"text": "Tab 2"},
},
}
assert _find_tab_insert_target(layout, target_tab="TAB-second") == "TAB-second"
assert find_tab_insert_target(layout, target_tab="TAB-second") == "TAB-second"
def test_find_tab_insert_target_unmatched_returns_none(self):
"""Test _find_tab_insert_target returns None when target_tab doesn't
"""Test find_tab_insert_target returns None when target_tab doesn't
match any tab name or ID, so the caller can return a descriptive error."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
@@ -1966,7 +1968,7 @@ class TestLayoutHelpers:
"meta": {"text": "Tab 2"},
},
}
assert _find_tab_insert_target(layout, target_tab="Nonexistent Tab") is None
assert find_tab_insert_target(layout, target_tab="Nonexistent Tab") is None
def test_find_tab_insert_target_empty_string_returns_none(self) -> None:
"""An empty-string target_tab is treated as specified-but-not-found,
@@ -1976,10 +1978,10 @@ class TestLayoutHelpers:
"TABS-main": {"children": ["TAB-first"], "type": "TABS"},
"TAB-first": {"children": [], "type": "TAB", "meta": {"text": "Tab 1"}},
}
assert _find_tab_insert_target(layout, target_tab="") is None
assert find_tab_insert_target(layout, target_tab="") is None
def test_find_tab_insert_target_tabs_under_root(self) -> None:
"""Test _find_tab_insert_target when TABS are under ROOT_ID (real layout)."""
"""Test find_tab_insert_target when TABS are under ROOT_ID (real layout)."""
layout = {
"ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"},
"GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]},
@@ -1987,10 +1989,10 @@ class TestLayoutHelpers:
"TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Overview"}},
"TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Details"}},
}
assert _find_tab_insert_target(layout) == "TAB-a"
assert find_tab_insert_target(layout) == "TAB-a"
def test_find_tab_insert_target_tabs_under_root_by_name(self) -> None:
"""Test _find_tab_insert_target matches tab name when TABS under ROOT_ID."""
"""Test find_tab_insert_target matches tab name when TABS under ROOT_ID."""
layout = {
"ROOT_ID": {"children": ["TABS-xxx"], "type": "ROOT"},
"GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]},
@@ -1998,31 +2000,31 @@ class TestLayoutHelpers:
"TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Overview"}},
"TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Details"}},
}
assert _find_tab_insert_target(layout, target_tab="Details") == "TAB-b"
assert find_tab_insert_target(layout, target_tab="Details") == "TAB-b"
def test_find_tab_insert_target_no_grid(self) -> None:
"""Test _find_tab_insert_target with missing GRID_ID."""
assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
"""Test find_tab_insert_target with missing GRID_ID."""
assert find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
def test_collect_available_tab_names_returns_display_names(self) -> None:
"""_collect_available_tab_names returns label + component ID for each tab."""
"""collect_available_tab_names returns label + component ID for each tab."""
layout = {
"GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
"TABS-x": {"children": ["TAB-a", "TAB-b"], "type": "TABS"},
"TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Overview"}},
"TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Details"}},
}
names = _collect_available_tab_names(layout)
names = collect_available_tab_names(layout)
assert names == ["Overview (TAB-a)", "Details (TAB-b)"]
def test_collect_available_tab_names_falls_back_to_id(self) -> None:
"""_collect_available_tab_names uses component ID only when text is empty."""
"""collect_available_tab_names uses component ID only when text is empty."""
layout = {
"GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
"TABS-x": {"children": ["TAB-a"], "type": "TABS"},
"TAB-a": {"children": [], "type": "TAB", "meta": {}},
}
names = _collect_available_tab_names(layout)
names = collect_available_tab_names(layout)
assert names == ["TAB-a"]
def test_collect_available_tab_names_duplicate_names(self) -> None:
@@ -2033,17 +2035,17 @@ class TestLayoutHelpers:
"TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Sales"}},
"TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Sales"}},
}
names = _collect_available_tab_names(layout)
names = collect_available_tab_names(layout)
assert names == ["Sales (TAB-a)", "Sales (TAB-b)"]
assert names[0] != names[1]
def test_collect_available_tab_names_no_tabs(self) -> None:
"""_collect_available_tab_names returns empty list for non-tabbed dashboards."""
"""collect_available_tab_names returns empty list for non-tabbed dashboards."""
layout = {
"GRID_ID": {"children": ["ROW-1"], "type": "GRID"},
"ROW-1": {"children": [], "type": "ROW"},
}
assert _collect_available_tab_names(layout) == []
assert collect_available_tab_names(layout) == []
def test_add_chart_to_layout_creates_column(self):
"""Test that _add_chart_to_layout creates ROW > COLUMN > CHART."""
@@ -2072,9 +2074,9 @@ class TestLayoutHelpers:
assert layout[chart_key]["meta"]["chartId"] == 42
def test_ensure_layout_structure_creates_missing(self):
"""Test _ensure_layout_structure creates GRID and ROOT if missing."""
"""Test ensure_layout_structure creates GRID and ROOT if missing."""
layout: dict = {}
_ensure_layout_structure(layout, "ROW-test", "GRID_ID")
ensure_layout_structure(layout, "ROW-test", "GRID_ID")
assert "ROOT_ID" in layout
assert "GRID_ID" in layout
@@ -2083,7 +2085,7 @@ class TestLayoutHelpers:
assert layout["DASHBOARD_VERSION_KEY"] == "v2"
def test_ensure_layout_structure_adds_to_tab(self):
"""Test _ensure_layout_structure adds row to tab parent."""
"""Test ensure_layout_structure adds row to tab parent."""
layout = {
"ROOT_ID": {"children": ["GRID_ID"], "type": "ROOT"},
"GRID_ID": {
@@ -2093,13 +2095,13 @@ class TestLayoutHelpers:
},
"TAB-first": {"children": ["ROW-existing"], "type": "TAB"},
}
_ensure_layout_structure(layout, "ROW-new", "TAB-first")
ensure_layout_structure(layout, "ROW-new", "TAB-first")
assert "ROW-new" in layout["TAB-first"]["children"]
assert "ROW-new" not in layout["GRID_ID"]["children"]
def test_ensure_layout_structure_tabs_under_root_no_grid_added(self):
"""Test _ensure_layout_structure does NOT add GRID_ID to ROOT_ID
"""Test ensure_layout_structure does NOT add GRID_ID to ROOT_ID
when TABS already exists as a ROOT_ID child.
Real Superset tabbed dashboards place TABS under ROOT_ID, not
@@ -2127,7 +2129,7 @@ class TestLayoutHelpers:
"parents": ["ROOT_ID", "TABS-xxx"],
},
}
_ensure_layout_structure(layout, "ROW-new", "TAB-a")
ensure_layout_structure(layout, "ROW-new", "TAB-a")
# Row added to the correct tab
assert "ROW-new" in layout["TAB-a"]["children"]
@@ -2136,14 +2138,14 @@ class TestLayoutHelpers:
assert layout["ROOT_ID"]["children"] == ["TABS-xxx"]
def test_ensure_layout_structure_no_tabs_adds_grid_to_root(self):
"""Test _ensure_layout_structure still adds GRID_ID to ROOT_ID
"""Test ensure_layout_structure still adds GRID_ID to ROOT_ID
when the dashboard has no tabs (non-tabbed dashboard regression check).
"""
layout = {
"ROOT_ID": {"children": [], "type": "ROOT"},
"GRID_ID": {"children": [], "type": "GRID", "parents": ["ROOT_ID"]},
}
_ensure_layout_structure(layout, "ROW-new", "GRID_ID")
ensure_layout_structure(layout, "ROW-new", "GRID_ID")
assert "GRID_ID" in layout["ROOT_ID"]["children"]
assert "ROW-new" in layout["GRID_ID"]["children"]

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,7 @@ EXPECTED_TOOL_OUTPUT_FIELDS = {
"list_themes": "themes",
"list_users": "users",
"manage_dashboard_certification": "changed_fields",
"manage_dashboard_markdown": "components",
"manage_dashboard_owners": "owners",
"manage_dashboard_roles": "roles",
"manage_native_filters": "filters",
@@ -143,6 +144,7 @@ MUTATING_TOOLS = {
"generate_dashboard",
"generate_explore_link",
"manage_dashboard_certification",
"manage_dashboard_markdown",
"manage_dashboard_owners",
"manage_dashboard_roles",
"manage_native_filters",
@@ -177,6 +179,7 @@ DESTRUCTIVE_TOOLS = {
"delete_dataset",
"delete_dataset_metric",
"execute_sql",
"manage_dashboard_markdown",
"manage_dashboard_owners",
"manage_dashboard_roles",
"manage_native_filters",

View File

@@ -121,6 +121,7 @@ TOOL_BUDGETS = {
"list_themes": 3_000,
"list_users": 2_900,
"manage_dashboard_certification": 1_900,
"manage_dashboard_markdown": 7_000,
"manage_dashboard_owners": 2_200,
"manage_dashboard_roles": 1_900,
"manage_native_filters": 9_600,