feat(mcp): support unsaved state in Explore and Dashboard tools (#37183)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Amin Ghadersohi
2026-02-25 09:25:23 -05:00
committed by GitHub
parent 1cd35bb102
commit 3084907931
5 changed files with 390 additions and 15 deletions

View File

@@ -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=(

View File

@@ -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

View File

@@ -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),))

View File

@@ -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")

View File

@@ -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: