Skip to content
Merged
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
82 changes: 82 additions & 0 deletions docs/admin_docs/configuration/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,88 @@ writes to the existing chart commands and does not bypass that prerequisite or
chart edit permissions. Link generation alone does not establish that a chart
was saved or that a provider query executed successfully.

## Canvas authoring and recovery

Canvas tools require the `CANVAS` feature flag and the caller's Canvas permissions.
`create_canvas` with omitted, `null`, or empty `ops` creates an empty canvas.
Supplying initial operations intentionally publishes their content in that call;
if an initial operation fails, the new canvas and widgets are not retained.
To stage changes privately, create a canvas, call `create_canvas_draft`, and use
the returned token and draft revision with `apply_canvas_draft_ops`. Only an
explicit `commit_canvas_draft` publishes those draft changes.

The native tool input schemas and `search_tools` results configured with
`MCP_TOOL_SEARCH_CONFIG["include_schemas"] = True` expose the canonical operation
grammar: `add`, `move`, `remove`, `set_props`, `place`, `set_scope`, and
`set_settings`, selected by the required `op` field. Arguments remain flat
(`token`, `revision`, `ops` for draft operations),
and operation fields use camelCase. Draft operation batches must be nonempty.
Native compact descriptions retain the input schema. Search results with
`include_schemas=False` deliberately omit schemas; flat Canvas tools have no
separate `request` instructions channel. Retrieve the full schema before
authoring operations, rather than relying on a truncated description.

An `add` operation supplies exactly one of `widgetId` (an existing widget) or
`widgetType` (a new widget). New-widget fields such as `props` and `name` cannot
be combined with `widgetId`. Use `get_widget_control_schema` for the selected
widget type's props. Omitting `move.layout` preserves the existing placement
layout when its parent is unchanged; moving to another parent allows reflow.
`set_scope.scope` and `set_settings.value` are required fields that accept `null`
to reset the specified override. Explicit resets survive a draft commit.

Chart, table, and filter placements reference a `widgetId`. Their effective
content is in the response's `widgets` map, including staged draft content.
`set_props` replaces the entire props object. For a follow-up dimension edit,
read `get_canvas_draft`, copy the effective props, update `dataBinding.dimensions`,
and submit the complete replacement to preserve unrelated props. A new widget removed from all placements
before commit is not published; any surviving placement keeps that widget.

Canvas error results add an optional `error_type` while retaining `error`,
validation `errors` with paths, and `operation` when an operation index is known.
Top-level MCP argument/transport errors, permission denials, missing canvases, and
unsupported definition versions retain their existing responses.

| `error_type` | Meaning | Recovery |
| --- | --- | --- |
| `ValidationError` | Invalid canvas metadata, operations, or resulting definition. | Correct the reported fields; a rejected batch leaves the draft unchanged. |
| `DraftUnavailable` | The token is malformed, unknown, expired, committed, discarded, or owned by another caller. These cases are intentionally indistinguishable. | Obtain an authorized fresh draft if needed; do not infer a reason or replay automatically. |
| `DraftRevisionConflict` | The submitted draft revision differs from the loaded draft. | Reload the draft and base subsequent changes on its current revision. |
| `CanvasRevisionConflict` | The published canvas changed before a mutating commit or conflicted with initial operations. | Start a fresh draft after a rejected mutating commit. |
| `WidgetRevisionConflict` | A shared widget changed even though the canvas did not. `changed_widgets` identifies the conflicts and `canvas_changed` is `false`. | Start a fresh draft; the rejected commit publishes none of its changes. |

Revision fields identify the state actually observed by the command:

| Field | Meaning |
| --- | --- |
| `base_revision` | Published canvas revision from which the draft was forked. |
| `draft_revision` | Current loaded draft revision, when available. Use a successful draft response's `revision` for its next write. |
| `current_canvas_revision` | Published canvas revision observed at a canvas/widget conflict check, when available; never a widget revision. |
| `canvas_revision` | Published revision returned by a successful commit, including an empty draft close. |
| `revision` | Compatibility field: the draft revision for draft responses/revision conflicts, or the observed canvas revision for canvas/widget conflicts and direct canvas writes. Prefer the explicit fields when interpreting conflicts. |

Unavailable revisions are omitted. Validation does not load a token merely to
enrich an error, and `DraftUnavailable` reveals no revisions. A stale draft
revision is checked before publishing or closing. For a mutating commit, a
canvas conflict takes precedence over any widget conflicts; missing widget IDs
must not be inferred from that response. An empty draft closes successfully
without writing or incrementing the canvas, even if the published canvas changed
since the fork, and reports the published revision observed by the command.
A draft whose changes cancel out, for example adding a new widget and then
removing it, is not empty: committing it publishes a new canvas revision, as
publishing the same operations without a draft would, and returns
`CanvasRevisionConflict` if the canvas changed since the fork.

For ranked results, specify both ordering and a limit in `dataBinding`, for
example `"orderBy": [{"field": "total_amount", "descending": true}], "rowLimit": 5`.
Use a saved metric name or the equivalent ad hoc metric label as the sort field.
Ascending order selects the smallest aggregates. A limit alone does not imply
ordering. The canvas page and `preview_widget` execute the binding through the
same chart-data pipeline, including dataset access checks. Preview rows, columns,
and permitted query text are evidence of the query that ran; they do not establish
that the binding answers a natural-language request. Query text requires
`can_view_query` on `Dashboard`. Visual rendering is optional and separate from
query execution.

## Quick Start

Get the MCP server running locally and connect an AI client in three steps.
Expand Down
4 changes: 2 additions & 2 deletions docs/static/resources/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -17974,7 +17974,7 @@
},
"/api/v1/canvas/draft/commit": {
"post": {
"description": "Replays the draft's operations on the canvas and deletes the draft. Rejected with 409 if the canvas changed since the draft started.",
"description": "Replays the draft's operations on the canvas and deletes the draft. Rejected with 409 for a stale draft revision, or if the canvas or a widget the draft changed was saved since the draft started. An empty draft closes without a write and returns the published revision observed by the command.",
"responses": {
"200": {
"description": "The canvas revision after the commit"
Expand All @@ -17983,7 +17983,7 @@
"$ref": "#/components/responses/404"
},
"409": {
"description": "The draft or the canvas changed"
"description": "The draft, canvas, or a changed widget conflicts"
},
"422": {
"description": "An operation no longer applies"
Expand Down
12 changes: 8 additions & 4 deletions superset/canvas/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,11 @@ def wrapped(self: CanvasRestApi, *args: Any, **kwargs: Any) -> Response:
return f(self, *args, **kwargs)
except (
CanvasNotFoundError,
CanvasDraftNotFoundError,
CanvasPlacementNotFoundError,
):
return self.response_404()
except CanvasDraftNotFoundError as ex:
return self.response(404, message="Not found", error_type=ex.recovery_type)
except CanvasForbiddenError:
return self.response_403()
except CanvasInvalidError as ex:
Expand Down Expand Up @@ -853,14 +854,17 @@ def commit_draft(self) -> Response:
summary: Commit a canvas draft
description: >-
Replays the draft's operations on the canvas and deletes the draft.
Rejected with 409 if the canvas changed since the draft started.
Rejected with 409 for a stale draft revision, or if the canvas or
a widget the draft changed was saved since the draft started.
An empty draft closes without a write and returns the published
revision observed by the command.
responses:
200:
description: The canvas revision after the commit
404:
$ref: '#/components/responses/404'
409:
description: The draft or the canvas changed
description: The draft, canvas, or a changed widget conflicts
422:
description: An operation no longer applies
"""
Expand All @@ -874,7 +878,7 @@ def commit_draft(self) -> Response:
200,
result={
"canvasId": draft.canvas_id,
"revision": result.revision if result else draft.base_revision,
"revision": result.revision,
},
)

Expand Down
10 changes: 8 additions & 2 deletions superset/canvas/definition/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ def apply(self, op: Operation) -> AppliedOperation:
removed = self.subtree(op.id)
for node_id in removed:
del self.nodes[node_id]
self._forget_unplaced()
touched = [Touch(node_id, FieldGroup.TREE) for node_id in removed]
return AppliedOperation(
logged,
Expand All @@ -262,6 +261,7 @@ def apply(self, op: Operation) -> AppliedOperation:
self.node(op.id)
overrides = self.scopes(op.kind)
if op.scope is None:
logged["scope"] = None
overrides.pop(op.id, None)
else:
overrides[op.id] = op.scope.model_dump(
Expand All @@ -271,6 +271,7 @@ def apply(self, op: Operation) -> AppliedOperation:
if isinstance(op, SetSettingsOp):
settings = self.canvas.setdefault("settings", {})
if op.value is None:
logged["value"] = None
settings.pop(op.key, None)
else:
settings[op.key] = op.value
Expand Down Expand Up @@ -429,7 +430,7 @@ def widget_of(self, node_id: str) -> tuple[str, StoredWidget]:
raise ValueError(f"{node_id!r} holds a widget that no longer exists")
return widget_id, stored

def _forget_unplaced(self) -> None:
def forget_unplaced(self) -> None:
"""Drop widgets created here that no placement holds any more."""
placed = {node.get("widgetId") for node in self.nodes.values()}
for widget_id in [
Expand Down Expand Up @@ -497,6 +498,7 @@ def apply_operations(
resolver: WidgetResolver | None = None,
new_id: Callable[[str, Container[str]], str] = new_placement_id,
pending: dict[str, StoredWidget] | None = None,
keep_unplaced: bool = False,
new_widget_id: Callable[[], str] = lambda: str(uuid4()),
) -> tuple[dict[str, Any], list[AppliedOperation]]:
"""
Expand All @@ -509,6 +511,8 @@ def apply_operations(
Widgets the operations create or change are kept in ``pending``, keyed by
UUID, which is updated only if every operation applies; the caller saves
them with the canvas. ``pending`` may already hold a draft's widgets.
``keep_unplaced`` retains new widgets needed to replay a draft's history;
otherwise new widgets without a final placement are dropped before saving.
"""
widget_types = get_widget_types() if widget_types is None else widget_types
resolver = resolver or get_widget_resolver()
Expand All @@ -535,6 +539,8 @@ def apply_operations(
except DefinitionValidationError as ex:
ex.operation = _culprit(ex, applied)
raise
if not keep_unplaced:
applier.forget_unplaced()
pending.clear()
pending.update(applier.pending)
return definition, applied
Expand Down
19 changes: 15 additions & 4 deletions superset/commands/canvas/apply_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,18 @@ def apply_or_raise(
definition: dict[str, Any],
ops: list[Operation],
pending: dict[str, StoredWidget] | None = None,
*,
keep_unplaced: bool = False,
) -> tuple[dict[str, Any], list[AppliedOperation]]:
"""
Apply ``ops`` to a copy of ``definition``, as one atomic batch; widgets
they create or change are added to ``pending``.
they create or change are added to ``pending``. Drafts keep unplaced new
widgets privately so their complete operation history remains replayable.
"""
try:
return apply_operations(definition, ops, pending=pending)
return apply_operations(
definition, ops, pending=pending, keep_unplaced=keep_unplaced
)
except OperationError as ex:
raise DefinitionInvalidError(
str(ex),
Expand Down Expand Up @@ -172,7 +177,9 @@ def _apply(self) -> ApplyResult:
canvas = CanvasDAO.lock(self._canvas_id)
current = canvas.revision
if self._strict and self._base_revision != current:
raise DraftConflictError(current, canvas_changed=True)
raise DraftConflictError(
current, canvas_changed=True, current_canvas_revision=current
)
if self._base_revision != current:
self._check_overlap(current, named_touches(self._ops))

Expand Down Expand Up @@ -265,7 +272,11 @@ def save_widgets(pending: dict[str, StoredWidget], canvas: Canvas) -> None:
row.schema_version = widget.schema_version
row.revision = widget.revision + 1
if changed:
raise DraftConflictError(canvas.revision, widgets=sorted(changed))
raise DraftConflictError(
canvas.revision,
widgets=sorted(changed),
current_canvas_revision=canvas.revision,
)
Comment on lines 274 to +279

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: A concurrent widget edit during a normal MCP apply raises DraftConflictError, which _apply_canvas_ops_impl does not catch, so the tool fails instead of returning the conflict payload.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Api mismatch

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/canvas/apply_ops.py
**Line:** 274:279
**Comment:**
	*Api Mismatch: A concurrent widget edit during a normal MCP apply raises `DraftConflictError`, which `_apply_canvas_ops_impl` does not catch, so the tool fails instead of returning the conflict payload.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

db.session.flush()


Expand Down
43 changes: 29 additions & 14 deletions superset/commands/canvas/draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
from superset.commands.canvas.apply_ops import (
apply_or_raise,
ApplyCanvasOperationsCommand,
ApplyResult,
placement_issues,
)
from superset.commands.canvas.exceptions import (
Expand Down Expand Up @@ -88,7 +87,8 @@ class CanvasDraft:
# The operations applied so far that change the canvas; commit replays
# them.
ops: list[dict[str, Any]] = field(default_factory=list)
# Widgets created or changed so far, by UUID; commit saves them.
# Widgets created or changed so far, by UUID, including new widgets needed
# to replay earlier operations. Commit saves only new widgets still placed.
widgets: dict[str, StoredWidget] = field(default_factory=dict)

def resolver(self) -> PendingWidgetResolver:
Expand Down Expand Up @@ -205,12 +205,14 @@ def __init__(self, token: str, revision: int, ops: list[Operation]) -> None:
def run(self) -> tuple[CanvasDraft, list[dict[str, Any]]]:
draft = load_draft(self._token)
if self._revision != draft.revision:
raise DraftConflictError(draft.revision)
raise DraftConflictError(draft.revision, draft_revision=draft.revision)
if issues := placement_issues(self._ops, draft.resolver()):
raise DefinitionInvalidError(
"The operations add widgets that cannot be placed", issues=issues
)
definition, applied = apply_or_raise(draft.definition, self._ops, draft.widgets)
definition, applied = apply_or_raise(
draft.definition, self._ops, draft.widgets, keep_unplaced=True
)
logged = [operation.op for operation in applied]
draft.definition = definition
draft.ops = [*draft.ops, *(a.op for a in applied if a.replay)]
Expand All @@ -219,6 +221,13 @@ def run(self) -> tuple[CanvasDraft, list[dict[str, Any]]]:
return draft, logged


@dataclass(frozen=True)
class CommitResult:
"""The revision written by a commit, or observed while closing an empty draft."""

revision: int


class CommitCanvasDraftCommand:
"""
Publish a draft: save its widgets and replay its operations on the canvas,
Expand All @@ -230,19 +239,25 @@ def __init__(self, token: str, revision: int) -> None:
self._token = token
self._revision = revision

def run(self) -> ApplyResult | None:
def run(self) -> CommitResult:
draft = load_draft(self._token)
if self._revision != draft.revision:
raise DraftConflictError(draft.revision)
result = None
raise DraftConflictError(draft.revision, draft_revision=draft.revision)
if draft.ops or draft.widgets:
result = ApplyCanvasOperationsCommand(
draft.canvas_id,
draft.base_revision,
OPERATIONS.validate_python(draft.ops),
strict=True,
widgets=draft.widgets,
).run()
try:
applied = ApplyCanvasOperationsCommand(
draft.canvas_id,
draft.base_revision,
OPERATIONS.validate_python(draft.ops),
strict=True,
widgets=draft.widgets,
).run()
except DraftConflictError as ex:
ex.draft_revision = draft.revision
raise
result = CommitResult(applied.revision)
else:
result = CommitResult(_editable_canvas(draft.canvas_id).revision)
DeleteCanvasDraftCommand(self._token).run()
return result

Expand Down
Loading
Loading