This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 30849079318 feat(mcp): support unsaved state in Explore and Dashboard
tools (#37183)
30849079318 is described below
commit 30849079318ad730347ceb5813f7c4200d6df151
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed Feb 25 09:25:23 2026 -0500
feat(mcp): support unsaved state in Explore and Dashboard tools (#37183)
Co-authored-by: Claude Opus 4.5 <[email protected]>
---
superset/mcp_service/chart/schemas.py | 68 ++++++++++-
superset/mcp_service/chart/tool/get_chart_data.py | 128 ++++++++++++++++++++-
superset/mcp_service/chart/tool/get_chart_info.py | 67 ++++++++++-
superset/mcp_service/dashboard/schemas.py | 43 ++++++-
.../dashboard/tool/get_dashboard_info.py | 99 +++++++++++++++-
5 files changed, 390 insertions(+), 15 deletions(-)
diff --git a/superset/mcp_service/chart/schemas.py
b/superset/mcp_service/chart/schemas.py
index 63007ef3d25..2d0bb0cafe7 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -109,6 +109,24 @@ class ChartInfo(BaseModel):
uuid: str | None = Field(None, description="Chart UUID")
tags: List[TagInfo] = Field(default_factory=list, description="Chart tags")
owners: List[UserInfo] = Field(default_factory=list, description="Chart
owners")
+
+ # Fields for unsaved state support
+ form_data_key: str | None = Field(
+ None,
+ description=(
+ "Cache key used to retrieve unsaved form_data. When present,
indicates "
+ "the form_data came from cache (unsaved edits) rather than the
saved chart."
+ ),
+ )
+ is_unsaved_state: bool = Field(
+ default=False,
+ description=(
+ "True if the form_data came from cache (unsaved edits) rather than
the "
+ "saved chart configuration. When true, the data reflects what the
user "
+ "sees in the Explore view, not the saved version."
+ ),
+ )
+
model_config = ConfigDict(from_attributes=True,
ser_json_timedelta="iso8601")
@model_serializer(mode="wrap", when_used="json")
@@ -200,12 +218,26 @@ class VersionedResponse(BaseModel):
class GetChartInfoRequest(BaseModel):
- """Request schema for get_chart_info with support for ID or UUID."""
+ """Request schema for get_chart_info with support for ID or UUID.
+
+ When form_data_key is provided, the tool will retrieve the unsaved chart
state
+ from cache, allowing you to explain what the user actually sees (not the
saved
+ version). This is useful when a user edits a chart in Explore but hasn't
saved yet.
+ """
identifier: Annotated[
int | str,
Field(description="Chart identifier - can be numeric ID or UUID
string"),
]
+ form_data_key: str | None = Field(
+ default=None,
+ description=(
+ "Optional cache key for retrieving unsaved chart state. When a
user "
+ "edits a chart in Explore but hasn't saved, the current state is
stored "
+ "with this key. If provided, the tool returns the current unsaved "
+ "configuration instead of the saved version."
+ ),
+ )
def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
@@ -788,9 +820,24 @@ class UpdateChartPreviewRequest(FormDataCacheControl):
class GetChartDataRequest(QueryCacheControl):
- """Request for chart data with cache control."""
+ """Request for chart data with cache control.
+
+ When form_data_key is provided, the tool will use the unsaved chart
configuration
+ from cache to query data, allowing you to get data for what the user
actually sees
+ (not the saved version). This is useful when a user edits a chart in
Explore but
+ hasn't saved yet.
+ """
identifier: int | str = Field(description="Chart identifier (ID, UUID)")
+ form_data_key: str | None = Field(
+ default=None,
+ description=(
+ "Optional cache key for retrieving unsaved chart state. When a
user "
+ "edits a chart in Explore but hasn't saved, the current state is
stored "
+ "with this key. If provided, the tool uses this configuration to
query "
+ "data instead of the saved chart configuration."
+ ),
+ )
limit: int | None = Field(
default=None,
description=(
@@ -866,9 +913,24 @@ class ChartData(BaseModel):
class GetChartPreviewRequest(QueryCacheControl):
- """Request for chart preview with cache control."""
+ """Request for chart preview with cache control.
+
+ When form_data_key is provided, the tool will render a preview using the
unsaved
+ chart configuration from cache, allowing you to preview what the user
actually sees
+ (not the saved version). This is useful when a user edits a chart in
Explore but
+ hasn't saved yet.
+ """
identifier: int | str = Field(description="Chart identifier (ID, UUID)")
+ form_data_key: str | None = Field(
+ default=None,
+ description=(
+ "Optional cache key for retrieving unsaved chart state. When a
user "
+ "edits a chart in Explore but hasn't saved, the current state is
stored "
+ "with this key. If provided, the tool renders a preview using this
"
+ "configuration instead of the saved chart configuration."
+ ),
+ )
format: Literal["url", "ascii", "table", "vega_lite"] = Field(
default="url",
description=(
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py
b/superset/mcp_service/chart/tool/get_chart_data.py
index 327052b423f..a9970814b34 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -20,6 +20,7 @@ MCP tool: get_chart_data
"""
import logging
+import time
from typing import Any, Dict, List, TYPE_CHECKING
from fastmcp import Context
@@ -29,6 +30,8 @@ from superset_core.mcp import tool
if TYPE_CHECKING:
from superset.models.slice import Slice
+from superset.commands.exceptions import CommandException
+from superset.commands.explore.form_data.parameters import CommandParameters
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
ChartData,
@@ -43,6 +46,21 @@ from superset.mcp_service.utils.schema_utils import
parse_request
logger = logging.getLogger(__name__)
+def _get_cached_form_data(form_data_key: str) -> str | None:
+ """Retrieve form_data from cache using form_data_key.
+
+ Returns the JSON string of form_data if found, None otherwise.
+ """
+ from superset.commands.explore.form_data.get import GetFormDataCommand
+
+ try:
+ cmd_params = CommandParameters(key=form_data_key)
+ return GetFormDataCommand(cmd_params).run()
+ except (KeyError, ValueError, CommandException) as e:
+ logger.warning("Failed to retrieve form_data from cache: %s", e)
+ return None
+
+
@tool(tags=["data"])
@parse_request(GetChartDataRequest)
async def get_chart_data( # noqa: C901
@@ -57,15 +75,22 @@ async def get_chart_data( # noqa: C901
- Multiple formats: json, csv, excel
- Cache control: use_cache, force_refresh, cache_timeout
- Optional row limit override (respects chart's configured limits)
+ - form_data_key: retrieves data using unsaved chart configuration from
Explore
+
+ When form_data_key is provided, the tool uses the cached (unsaved) chart
+ configuration to query data, allowing you to get data for what the user
+ actually sees in the Explore view (not the saved version).
Returns underlying data in requested format with cache status.
"""
await ctx.info(
- "Starting chart data retrieval: identifier=%s, format=%s, limit=%s"
+ "Starting chart data retrieval: identifier=%s, format=%s, limit=%s, "
+ "form_data_key=%s"
% (
request.identifier,
request.format,
request.limit,
+ request.form_data_key,
)
)
await ctx.debug(
@@ -122,20 +147,111 @@ async def get_chart_data( # noqa: C901
)
logger.info("Getting data for chart %s: %s", chart.id,
chart.slice_name)
- import time
-
start_time = time.time()
+ # Track whether we're using unsaved state
+ using_unsaved_state = False
+ cached_form_data_dict = None
+
try:
await ctx.report_progress(2, 4, "Preparing data query")
from superset.charts.schemas import ChartDataQueryContextSchema
from superset.commands.chart.data.get_data_command import
ChartDataCommand
+ # Check if form_data_key is provided - use cached form_data instead
+ if request.form_data_key:
+ await ctx.info(
+ "Retrieving unsaved chart state from cache:
form_data_key=%s"
+ % (request.form_data_key,)
+ )
+ if cached_form_data :=
_get_cached_form_data(request.form_data_key):
+ try:
+ parsed_form_data = utils_json.loads(cached_form_data)
+ # Only use if it's actually a dict (not null, list,
etc.)
+ if isinstance(parsed_form_data, dict):
+ cached_form_data_dict = parsed_form_data
+ using_unsaved_state = True
+ await ctx.info(
+ "Using cached form_data from form_data_key "
+ "for data query"
+ )
+ else:
+ await ctx.warning(
+ "Cached form_data is not a JSON object. "
+ "Falling back to saved chart configuration."
+ )
+ except (TypeError, ValueError) as e:
+ await ctx.warning(
+ "Failed to parse cached form_data: %s. "
+ "Falling back to saved chart configuration." %
str(e)
+ )
+ else:
+ await ctx.warning(
+ "form_data_key provided but no cached data found. "
+ "The cache may have expired. Using saved chart
configuration."
+ )
+
# Use the chart's saved query_context - this is the key!
# The query_context contains all the information needed to
reproduce
# the chart's data exactly as shown in the visualization
query_context_json = None
- if chart.query_context:
+
+ # If using cached form_data, we need to build query_context from it
+ if using_unsaved_state and cached_form_data_dict is not None:
+ # Build query context from cached form_data (unsaved state)
+ from superset.common.query_context_factory import
QueryContextFactory
+
+ factory = QueryContextFactory()
+ row_limit = (
+ request.limit
+ or cached_form_data_dict.get("row_limit")
+ or current_app.config["ROW_LIMIT"]
+ )
+
+ # Get datasource info from cached form_data or fall back to
chart
+ datasource_id = cached_form_data_dict.get(
+ "datasource_id", chart.datasource_id
+ )
+ datasource_type = cached_form_data_dict.get(
+ "datasource_type", chart.datasource_type
+ )
+
+ # Handle different chart types that have different form_data
+ # structures. Some charts use "metric" (singular), not
"metrics"
+ # (plural): big_number, big_number_total, pop_kpi.
+ # These charts also don't have groupby columns.
+ cached_viz_type = cached_form_data_dict.get(
+ "viz_type", chart.viz_type or ""
+ )
+ if cached_viz_type in ("big_number", "big_number_total",
"pop_kpi"):
+ metric = cached_form_data_dict.get("metric")
+ cached_metrics = [metric] if metric else []
+ cached_groupby: list[str] = []
+ else:
+ cached_metrics = cached_form_data_dict.get("metrics", [])
+ cached_groupby = cached_form_data_dict.get("groupby", [])
+
+ query_context = factory.create(
+ datasource={
+ "id": datasource_id,
+ "type": datasource_type,
+ },
+ queries=[
+ {
+ "filters": cached_form_data_dict.get("filters",
[]),
+ "columns": cached_groupby,
+ "metrics": cached_metrics,
+ "row_limit": row_limit,
+ "order_desc":
cached_form_data_dict.get("order_desc", True),
+ }
+ ],
+ form_data=cached_form_data_dict,
+ force=request.force_refresh,
+ )
+ await ctx.debug(
+ "Built query_context from cached form_data (unsaved state)"
+ )
+ elif chart.query_context:
try:
query_context_json = utils_json.loads(chart.query_context)
await ctx.debug(
@@ -146,7 +262,7 @@ async def get_chart_data( # noqa: C901
"Failed to parse chart query_context: %s" % str(e)
)
- if query_context_json is None:
+ if query_context_json is None and not using_unsaved_state:
# Fallback: Chart has no saved query_context
# This can happen with older charts that haven't been re-saved
await ctx.warning(
@@ -300,7 +416,7 @@ async def get_chart_data( # noqa: C901
form_data=form_data,
force=request.force_refresh,
)
- else:
+ elif query_context_json is not None:
# Apply request overrides to the saved query_context
query_context_json["force"] = request.force_refresh
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py
b/superset/mcp_service/chart/tool/get_chart_info.py
index c9270d33fff..de9dc314e6f 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -24,6 +24,8 @@ import logging
from fastmcp import Context
from superset_core.mcp import tool
+from superset.commands.exceptions import CommandException
+from superset.commands.explore.form_data.parameters import CommandParameters
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
ChartError,
@@ -37,6 +39,21 @@ from superset.mcp_service.utils.schema_utils import
parse_request
logger = logging.getLogger(__name__)
+def _get_cached_form_data(form_data_key: str) -> str | None:
+ """Retrieve form_data from cache using form_data_key.
+
+ Returns the JSON string of form_data if found, None otherwise.
+ """
+ from superset.commands.explore.form_data.get import GetFormDataCommand
+
+ try:
+ cmd_params = CommandParameters(key=form_data_key)
+ return GetFormDataCommand(cmd_params).run()
+ except (KeyError, ValueError, CommandException) as e:
+ logger.warning("Failed to retrieve form_data from cache: %s", e)
+ return None
+
+
@tool(tags=["discovery"])
@parse_request(GetChartInfoRequest)
async def get_chart_info(
@@ -48,6 +65,8 @@ async def get_chart_info(
- URL field links to the chart's explore page in Superset
- Use numeric ID or UUID string (NOT chart name)
- To find a chart ID, use the list_charts tool first
+ - When form_data_key is provided, returns the unsaved chart configuration
+ (what the user sees in Explore) instead of the saved version
Example usage:
```json
@@ -63,12 +82,22 @@ async def get_chart_info(
}
```
+ With unsaved state (form_data_key from Explore URL):
+ ```json
+ {
+ "identifier": 123,
+ "form_data_key": "abc123def456"
+ }
+ ```
+
Returns chart details including name, type, and URL.
"""
from superset.daos.chart import ChartDAO
+ from superset.utils import json as utils_json
await ctx.info(
- "Retrieving chart information: identifier=%s" % (request.identifier,)
+ "Retrieving chart information: identifier=%s, form_data_key=%s"
+ % (request.identifier, request.form_data_key)
)
with event_logger.log_context(action="mcp.get_chart_info.lookup"):
@@ -84,9 +113,41 @@ async def get_chart_info(
result = tool.run_tool(request.identifier)
if isinstance(result, ChartInfo):
+ # If form_data_key is provided, override form_data with cached version
+ if request.form_data_key:
+ await ctx.info(
+ "Retrieving unsaved chart state from cache: form_data_key=%s"
+ % (request.form_data_key,)
+ )
+ cached_form_data = _get_cached_form_data(request.form_data_key)
+
+ if cached_form_data:
+ try:
+ result.form_data = utils_json.loads(cached_form_data)
+ result.form_data_key = request.form_data_key
+ result.is_unsaved_state = True
+
+ # Update viz_type from cached form_data if present
+ if result.form_data and "viz_type" in result.form_data:
+ result.viz_type = result.form_data["viz_type"]
+
+ await ctx.info(
+ "Chart form_data overridden with unsaved state from
cache"
+ )
+ except (TypeError, ValueError) as e:
+ await ctx.warning(
+ "Failed to parse cached form_data: %s. "
+ "Using saved chart configuration." % (str(e),)
+ )
+ else:
+ await ctx.warning(
+ "form_data_key provided but no cached data found. "
+ "The cache may have expired. Using saved chart
configuration."
+ )
+
await ctx.info(
- "Chart information retrieved successfully: chart_name=%s"
- % (result.slice_name,)
+ "Chart information retrieved successfully: chart_name=%s, "
+ "is_unsaved_state=%s" % (result.slice_name,
result.is_unsaved_state)
)
else:
await ctx.warning("Chart retrieval failed: error=%s" % (str(result),))
diff --git a/superset/mcp_service/dashboard/schemas.py
b/superset/mcp_service/dashboard/schemas.py
index 3b4f520cfc1..62e4b46a0ec 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -270,7 +270,13 @@ class ListDashboardsRequest(MetadataCacheControl):
class GetDashboardInfoRequest(MetadataCacheControl):
- """Request schema for get_dashboard_info with support for ID, UUID, or
slug."""
+ """Request schema for get_dashboard_info with support for ID, UUID, or
slug.
+
+ When permalink_key is provided, the tool will retrieve the dashboard's
filter
+ state from the permalink, allowing you to see what filters the user has
applied
+ (not just the default filter state). This is useful when a user applies
filters
+ in a dashboard but the URL contains a permalink_key.
+ """
identifier: Annotated[
int | str,
@@ -278,6 +284,15 @@ class GetDashboardInfoRequest(MetadataCacheControl):
description="Dashboard identifier - can be numeric ID, UUID
string, or slug"
),
]
+ permalink_key: str | None = Field(
+ default=None,
+ description=(
+ "Optional permalink key for retrieving dashboard filter state.
When a "
+ "user applies filters in a dashboard, the state can be persisted
in a "
+ "permalink. If provided, the tool returns the filter configuration
"
+ "from that permalink."
+ ),
+ )
class DashboardInfo(BaseModel):
@@ -320,6 +335,32 @@ class DashboardInfo(BaseModel):
charts: List[ChartInfo] = Field(
default_factory=list, description="Dashboard charts"
)
+
+ # Fields for permalink/filter state support
+ permalink_key: str | None = Field(
+ None,
+ description=(
+ "Permalink key used to retrieve filter state. When present,
indicates "
+ "the filter_state came from a permalink rather than the default
dashboard."
+ ),
+ )
+ filter_state: Dict[str, Any] | None = Field(
+ None,
+ description=(
+ "Filter state from permalink. Contains dataMask (native filter
values), "
+ "activeTabs, anchor, and urlParams. When present, represents the
actual "
+ "filters the user has applied to the dashboard."
+ ),
+ )
+ is_permalink_state: bool = Field(
+ default=False,
+ description=(
+ "True if the filter_state came from a permalink rather than the
default "
+ "dashboard configuration. When true, the filter_state reflects
what the "
+ "user sees in the dashboard, not the default filter state."
+ ),
+ )
+
model_config = ConfigDict(from_attributes=True,
ser_json_timedelta="iso8601")
@model_serializer(mode="wrap", when_used="json")
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
index d567c054949..ebca60a7bb7 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
@@ -28,6 +28,8 @@ from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp import tool
+from superset.dashboards.permalink.exceptions import
DashboardPermalinkGetFailedError
+from superset.dashboards.permalink.types import DashboardPermalinkValue
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
dashboard_serializer,
@@ -41,6 +43,21 @@ from superset.mcp_service.utils.schema_utils import
parse_request
logger = logging.getLogger(__name__)
+def _get_permalink_state(permalink_key: str) -> DashboardPermalinkValue | None:
+ """Retrieve dashboard filter state from permalink.
+
+ Returns the permalink value containing dashboardId and state if found,
+ None otherwise.
+ """
+ from superset.commands.dashboard.permalink.get import
GetDashboardPermalinkCommand
+
+ try:
+ return GetDashboardPermalinkCommand(permalink_key).run()
+ except DashboardPermalinkGetFailedError as e:
+ logger.warning("Failed to retrieve permalink state: %s", e)
+ return None
+
+
@tool(tags=["discovery"])
@parse_request(GetDashboardInfoRequest)
async def get_dashboard_info(
@@ -50,8 +67,30 @@ async def get_dashboard_info(
Get dashboard metadata by ID, UUID, or slug.
Returns title, charts, and layout details.
+
+ When permalink_key is provided, also returns the filter state from that
+ permalink, allowing you to see what filters the user has applied to the
+ dashboard (not just the default filter state).
+
+ Example usage:
+ ```json
+ {
+ "identifier": 123
+ }
+ ```
+
+ With permalink (filter state from URL):
+ ```json
+ {
+ "identifier": 123,
+ "permalink_key": "abc123def456"
+ }
+ ```
"""
- await ctx.info("Retrieving dashboard information: %s" %
(request.identifier,))
+ await ctx.info(
+ "Retrieving dashboard information: identifier=%s, permalink_key=%s"
+ % (request.identifier, request.permalink_key)
+ )
await ctx.debug(
"Metadata cache settings: use_cache=%s, refresh_metadata=%s,
force_refresh=%s"
% (request.use_cache, request.refresh_metadata, request.force_refresh)
@@ -73,14 +112,70 @@ async def get_dashboard_info(
result = tool.run_tool(request.identifier)
if isinstance(result, DashboardInfo):
+ # If permalink_key is provided, retrieve filter state
+ if request.permalink_key:
+ await ctx.info(
+ "Retrieving filter state from permalink: permalink_key=%s"
+ % (request.permalink_key,)
+ )
+ permalink_value = _get_permalink_state(request.permalink_key)
+
+ if permalink_value:
+ # Verify the permalink belongs to the requested dashboard
+ # dashboardId in permalink is stored as str, result.id is
int
+ permalink_dashboard_id = permalink_value.get("dashboardId")
+ try:
+ permalink_dashboard_id_int = (
+ int(permalink_dashboard_id)
+ if permalink_dashboard_id
+ else None
+ )
+ except (ValueError, TypeError):
+ permalink_dashboard_id_int = None
+
+ if (
+ permalink_dashboard_id_int is not None
+ and permalink_dashboard_id_int != result.id
+ ):
+ await ctx.warning(
+ "permalink_key dashboardId (%s) does not match "
+ "requested dashboard id (%s); ignoring permalink "
+ "filter state." % (permalink_dashboard_id,
result.id)
+ )
+ else:
+ # Extract the state from permalink value
+ # Handle None or non-dict state gracefully
+ raw_state = permalink_value.get("state")
+ permalink_state = (
+ dict(raw_state) if isinstance(raw_state, dict)
else {}
+ )
+ result.permalink_key = request.permalink_key
+ result.filter_state = permalink_state
+ result.is_permalink_state = True
+
+ await ctx.info(
+ "Filter state retrieved from permalink: "
+ "has_dataMask=%s, has_activeTabs=%s"
+ % (
+ "dataMask" in permalink_state,
+ "activeTabs" in permalink_state,
+ )
+ )
+ else:
+ await ctx.warning(
+ "permalink_key provided but no permalink found. "
+ "The permalink may have expired or is invalid."
+ )
+
await ctx.info(
"Dashboard information retrieved successfully: id=%s,
title=%s, "
- "chart_count=%s, published=%s"
+ "chart_count=%s, published=%s, is_permalink_state=%s"
% (
result.id,
result.dashboard_title,
result.chart_count,
result.published,
+ result.is_permalink_state,
)
)
else: