@@ -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"
0 commit comments