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:

Reply via email to