Skip to content

Commit d36e38c

Browse files
fix(mcp): handle malformed source metadata in duplicate_dashboard
CopyDashboardCommand re-parses the source's stored params/json_metadata via set_dash_metadata; on malformed JSON this raises ValueError/ JSONDecodeError, which the transaction handler does not wrap as DashboardCopyError (it only catches SQLAlchemyError). The error escaped as a hard tool failure instead of a structured response. Catch the parse error and return a normal DuplicateDashboardResponse error. Also extract _resolve_source / _refetch_and_serialize / _safe_rollback helpers to keep duplicate_dashboard under the C901 complexity limit, and add a regression test for the malformed-metadata path.
1 parent 05dabda commit d36e38c

2 files changed

Lines changed: 164 additions & 80 deletions

File tree

superset/mcp_service/dashboard/tool/duplicate_dashboard.py

Lines changed: 132 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,113 @@ def _serialize_new_dashboard(dashboard: Any) -> tuple[DashboardInfo, str]:
139139
return _sanitize_dashboard_info_for_llm_context(info), dashboard_url
140140

141141

142+
def _safe_rollback(context_label: str) -> None:
143+
"""Roll back the current DB session, swallowing rollback failures.
144+
145+
A failed operation can leave the shared session in an invalid
146+
transaction state; rolling back keeps later ORM use in the same request
147+
lifecycle from inheriting the broken transaction.
148+
"""
149+
from superset import db
150+
151+
try:
152+
db.session.rollback() # pylint: disable=consider-using-transaction
153+
except SQLAlchemyError:
154+
logger.warning(
155+
"Database rollback failed during %s error handling",
156+
context_label,
157+
exc_info=True,
158+
)
159+
160+
161+
def _refetch_and_serialize(
162+
new_dashboard: Any, dashboard_title: str
163+
) -> tuple[DashboardInfo, str]:
164+
"""Re-fetch the new dashboard with eager-loaded relationships.
165+
166+
The eager load avoids lazy-loading on a session the command's commit may
167+
have invalidated. If the re-fetch fails, the failed transaction is rolled
168+
back and a minimal response is returned instead.
169+
"""
170+
from sqlalchemy.orm import subqueryload
171+
172+
from superset.daos.dashboard import DashboardDAO
173+
from superset.models.dashboard import Dashboard
174+
from superset.models.slice import Slice
175+
176+
try:
177+
dashboard = (
178+
DashboardDAO.find_by_id(
179+
new_dashboard.id,
180+
query_options=[
181+
subqueryload(Dashboard.slices).subqueryload(Slice.tags),
182+
subqueryload(Dashboard.tags),
183+
],
184+
)
185+
or new_dashboard
186+
)
187+
return _serialize_new_dashboard(dashboard)
188+
except SQLAlchemyError:
189+
logger.warning(
190+
"Re-fetch of dashboard %s failed; returning minimal response",
191+
new_dashboard.id,
192+
exc_info=True,
193+
)
194+
_safe_rollback("dashboard re-fetch")
195+
dashboard_url = (
196+
f"{get_superset_base_url()}/superset/dashboard/{new_dashboard.id}/"
197+
)
198+
info = _sanitize_dashboard_info_for_llm_context(
199+
DashboardInfo(
200+
id=new_dashboard.id,
201+
dashboard_title=dashboard_title,
202+
url=dashboard_url,
203+
)
204+
)
205+
return info, dashboard_url
206+
207+
208+
async def _resolve_source(
209+
request: DuplicateDashboardRequest, ctx: Context
210+
) -> tuple[Any, DuplicateDashboardResponse | None]:
211+
"""Resolve and authorize the source dashboard.
212+
213+
Returns ``(source, None)`` on success, or ``(None, error_response)`` when
214+
the dashboard is missing or inaccessible.
215+
"""
216+
from superset.commands.dashboard.exceptions import (
217+
DashboardAccessDeniedError,
218+
DashboardNotFoundError,
219+
)
220+
from superset.daos.dashboard import DashboardDAO
221+
222+
with event_logger.log_context(action="mcp.duplicate_dashboard.lookup"):
223+
try:
224+
return DashboardDAO.get_by_id_or_slug(str(request.dashboard_id)), None
225+
except DashboardNotFoundError:
226+
await ctx.warning(
227+
"Dashboard not found for duplication: dashboard_id=%s"
228+
% (request.dashboard_id,)
229+
)
230+
return None, DuplicateDashboardResponse(
231+
error=(
232+
f"Dashboard '{request.dashboard_id}' not found. "
233+
"Use list_dashboards to get valid dashboard IDs."
234+
),
235+
)
236+
except DashboardAccessDeniedError:
237+
await ctx.warning(
238+
"Dashboard access denied for duplication: dashboard_id=%s"
239+
% (request.dashboard_id,)
240+
)
241+
return None, DuplicateDashboardResponse(
242+
error=(
243+
f"You don't have access to dashboard "
244+
f"'{request.dashboard_id}', so it cannot be duplicated."
245+
),
246+
)
247+
248+
142249
@tool(
143250
tags=["mutate"],
144251
class_permission_name="Dashboard",
@@ -170,40 +277,15 @@ async def duplicate_dashboard(
170277

171278
from superset.commands.dashboard.copy import CopyDashboardCommand
172279
from superset.commands.dashboard.exceptions import (
173-
DashboardAccessDeniedError,
174280
DashboardCopyError,
175281
DashboardForbiddenError,
176282
DashboardInvalidError,
177-
DashboardNotFoundError,
178283
)
179-
from superset.daos.dashboard import DashboardDAO
180284

181285
try:
182-
with event_logger.log_context(action="mcp.duplicate_dashboard.lookup"):
183-
try:
184-
source = DashboardDAO.get_by_id_or_slug(str(request.dashboard_id))
185-
except DashboardNotFoundError:
186-
await ctx.warning(
187-
"Dashboard not found for duplication: dashboard_id=%s"
188-
% (request.dashboard_id,)
189-
)
190-
return DuplicateDashboardResponse(
191-
error=(
192-
f"Dashboard '{request.dashboard_id}' not found. "
193-
"Use list_dashboards to get valid dashboard IDs."
194-
),
195-
)
196-
except DashboardAccessDeniedError:
197-
await ctx.warning(
198-
"Dashboard access denied for duplication: dashboard_id=%s"
199-
% (request.dashboard_id,)
200-
)
201-
return DuplicateDashboardResponse(
202-
error=(
203-
f"You don't have access to dashboard "
204-
f"'{request.dashboard_id}', so it cannot be duplicated."
205-
),
206-
)
286+
source, error_response = await _resolve_source(request, ctx)
287+
if error_response is not None:
288+
return error_response
207289

208290
data, layout_has_charts = _build_copy_payload(
209291
source, request.dashboard_title, request.duplicate_slices
@@ -227,50 +309,9 @@ async def duplicate_dashboard(
227309
with event_logger.log_context(action="mcp.duplicate_dashboard.copy"):
228310
new_dashboard = CopyDashboardCommand(source, data).run()
229311

230-
# Re-fetch with eager-loaded relationships to avoid lazy-loading on
231-
# a session that the command's commit may have invalidated.
232-
from sqlalchemy.orm import subqueryload
233-
234-
from superset.models.dashboard import Dashboard
235-
from superset.models.slice import Slice
236-
237-
try:
238-
new_dashboard = (
239-
DashboardDAO.find_by_id(
240-
new_dashboard.id,
241-
query_options=[
242-
subqueryload(Dashboard.slices).subqueryload(Slice.tags),
243-
subqueryload(Dashboard.tags),
244-
],
245-
)
246-
or new_dashboard
247-
)
248-
info, dashboard_url = _serialize_new_dashboard(new_dashboard)
249-
except SQLAlchemyError:
250-
logger.warning(
251-
"Re-fetch of dashboard %s failed; returning minimal response",
252-
new_dashboard.id,
253-
exc_info=True,
254-
)
255-
from superset import db
256-
257-
try:
258-
db.session.rollback() # pylint: disable=consider-using-transaction
259-
except SQLAlchemyError:
260-
logger.warning(
261-
"Database rollback failed during dashboard re-fetch error handling",
262-
exc_info=True,
263-
)
264-
dashboard_url = (
265-
f"{get_superset_base_url()}/superset/dashboard/{new_dashboard.id}/"
266-
)
267-
info = _sanitize_dashboard_info_for_llm_context(
268-
DashboardInfo(
269-
id=new_dashboard.id,
270-
dashboard_title=request.dashboard_title,
271-
url=dashboard_url,
272-
)
273-
)
312+
info, dashboard_url = _refetch_and_serialize(
313+
new_dashboard, request.dashboard_title
314+
)
274315

275316
logger.info(
276317
"Duplicated dashboard %s into dashboard %s (duplicate_slices=%s)",
@@ -304,18 +345,29 @@ async def duplicate_dashboard(
304345
),
305346
)
306347
except DashboardCopyError as exc:
307-
from superset import db
308-
309-
try:
310-
db.session.rollback() # pylint: disable=consider-using-transaction
311-
except SQLAlchemyError:
312-
logger.warning(
313-
"Database rollback failed during error handling", exc_info=True
314-
)
348+
_safe_rollback("dashboard duplication")
315349
await ctx.error("Dashboard duplication failed: %s" % (str(exc),))
316350
return DuplicateDashboardResponse(
317351
error=f"Failed to duplicate dashboard: {exc}",
318352
)
353+
except (ValueError, TypeError) as exc:
354+
# Malformed stored metadata on the source (e.g. invalid json_metadata
355+
# or params) surfaces as a JSON/parse error from CopyDashboardCommand
356+
# rather than a DashboardCopyError, because the transaction handler
357+
# only wraps SQLAlchemyError. Return a structured response instead of
358+
# letting it escape as a hard tool failure.
359+
_safe_rollback("dashboard duplication")
360+
await ctx.error(
361+
"Dashboard duplication failed parsing source metadata: %s: %s"
362+
% (type(exc).__name__, str(exc))
363+
)
364+
return DuplicateDashboardResponse(
365+
error=(
366+
f"Dashboard '{request.dashboard_id}' could not be duplicated "
367+
"because its stored metadata is invalid. Open and re-save the "
368+
"source dashboard to repair it, then try again."
369+
),
370+
)
319371
except Exception as exc:
320372
await ctx.error(
321373
"Unexpected error duplicating dashboard: %s: %s"

tests/unit_tests/mcp_service/dashboard/tool/test_duplicate_dashboard.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,38 @@ async def test_refetch_failure_rolls_back_and_returns_minimal_response(
452452
assert "/superset/dashboard/7/" in content["dashboard_url"]
453453

454454

455+
@patch("superset.commands.dashboard.copy.CopyDashboardCommand")
456+
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
457+
@pytest.mark.asyncio
458+
async def test_malformed_metadata_returns_structured_error(
459+
mock_get_by_id_or_slug: Mock,
460+
mock_copy_cmd_cls: Mock,
461+
mcp_server: object,
462+
) -> None:
463+
"""Malformed source metadata yields a structured error, not a crash.
464+
465+
The copy command parses the source's stored params/json_metadata again
466+
via ``set_dash_metadata``; on malformed JSON that raises a
467+
``ValueError``/``JSONDecodeError`` which the transaction handler does not
468+
wrap as ``DashboardCopyError``. The tool must catch it and return a normal
469+
error response rather than letting it escape as a hard tool failure.
470+
"""
471+
source = _mock_dashboard(id=1, slices=[_mock_chart(id=10)])
472+
mock_get_by_id_or_slug.return_value = source
473+
mock_copy_cmd_cls.return_value.run.side_effect = ValueError("Expecting value")
474+
475+
with patch("superset.db.session"):
476+
async with Client(mcp_server) as client:
477+
result = await client.call_tool(
478+
"duplicate_dashboard",
479+
{"request": {"dashboard_id": 1, "dashboard_title": "Copy"}},
480+
)
481+
482+
content = result.structured_content
483+
assert content["dashboard"] is None
484+
assert "metadata is invalid" in (content["error"] or "")
485+
486+
455487
def test_title_xss_only_rejected_by_schema() -> None:
456488
"""A title that sanitizes to nothing is rejected with a clear error."""
457489
from pydantic import ValidationError

0 commit comments

Comments
 (0)