This is an automated email from the ASF dual-hosted git repository.

alexandrusoare 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 33585b0480c feat(mcp): make form_data_key optional in 
update_chart_preview (#39680)
33585b0480c is described below

commit 33585b0480c5c95b114e09c9d2c4b14ddb774310
Author: Alexandru Soare <[email protected]>
AuthorDate: Mon May 25 15:09:36 2026 +0300

    feat(mcp): make form_data_key optional in update_chart_preview (#39680)
---
 superset/mcp_service/chart/schemas.py              |  8 ++-
 .../mcp_service/chart/tool/update_chart_preview.py | 47 +++++++++++++-
 .../chart/tool/test_update_chart_preview.py        | 72 +++++++++++++++++++++-
 3 files changed, 123 insertions(+), 4 deletions(-)

diff --git a/superset/mcp_service/chart/schemas.py 
b/superset/mcp_service/chart/schemas.py
index a711ce535bb..5a4fee99bff 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -1771,7 +1771,13 @@ class UpdateChartRequest(QueryCacheControl):
 
 
 class UpdateChartPreviewRequest(FormDataCacheControl):
-    form_data_key: str = Field(..., description="Existing form_data_key to 
update")
+    form_data_key: str | None = Field(
+        None,
+        description=(
+            "Existing form_data_key to update"
+            " (omit for fresh preview from config + dataset_id)"
+        ),
+    )
     dataset_id: int | str = Field(..., description="Dataset ID or UUID")
     config: ChartConfig = Field(..., description="Chart configuration")
     generate_preview: bool = True
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py 
b/superset/mcp_service/chart/tool/update_chart_preview.py
index d130411f677..4fc9a26c955 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -66,6 +66,23 @@ INVALID_FORM_DATA_KEY_WARNING = (
 )
 
 
+def _find_dataset(dataset_id: int | str) -> Any | None:
+    """Look up a dataset by numeric ID or UUID and check access."""
+    from superset.daos.dataset import DatasetDAO
+    from superset.mcp_service.auth import has_dataset_access
+
+    if isinstance(dataset_id, int) or (
+        isinstance(dataset_id, str) and dataset_id.isdigit()
+    ):
+        dataset = DatasetDAO.find_by_id(int(dataset_id))
+    else:
+        dataset = DatasetDAO.find_by_id(dataset_id, id_column="uuid")
+
+    if dataset and not has_dataset_access(dataset):
+        return None
+    return dataset
+
+
 def _get_previous_form_data(form_data_key: str) -> dict[str, Any] | None:
     """Retrieve the previously cached form_data."""
     from superset.commands.exceptions import CommandException
@@ -102,10 +119,11 @@ def update_chart_preview(  # noqa: C901
 
     IMPORTANT:
     - Modifies cached form_data from generate_chart (save_chart=False)
-    - Original form_data_key is invalidated, new one returned
+    - Original form_data_key (when provided) is invalidated, new one returned
     - LLM clients MUST display explore_url to users
 
     Use when:
+    - Creating a fresh preview from config + dataset_id (omit form_data_key)
     - Modifying preview before deciding to save
     - Iterating on chart design without creating permanent charts
     - Testing different configurations
@@ -118,6 +136,33 @@ def update_chart_preview(  # noqa: C901
         # config is already a typed ChartConfig (validated by Pydantic)
         config = request.config
 
+        # Validate dataset exists and user has access
+        with 
event_logger.log_context(action="mcp.update_chart_preview.dataset_lookup"):
+            dataset = _find_dataset(request.dataset_id)
+
+            if not dataset:
+                return {
+                    "chart": None,
+                    "error": {
+                        "error_type": "dataset_not_found",
+                        "message": (f"Dataset not found: 
{request.dataset_id}"),
+                        "details": (
+                            f"No dataset found with identifier "
+                            f"'{request.dataset_id}'. This could "
+                            f"be an invalid ID/UUID or a "
+                            f"permissions issue."
+                        ),
+                        "suggestions": [
+                            "Verify the dataset ID or UUID",
+                            "Check dataset access permissions",
+                            "Use list_datasets to find available datasets",
+                        ],
+                    },
+                    "success": False,
+                    "schema_version": "2.0",
+                    "api_version": "v1",
+                }
+
         with 
event_logger.log_context(action="mcp.update_chart_preview.form_data"):
             # Map the new config to form_data format
             # Pass dataset_id to enable column type checking
diff --git 
a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py 
b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
index caeb1e7a747..fe5c88b9d91 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
@@ -422,6 +422,24 @@ class TestUpdateChartPreview:
             )
             assert request.form_data_key == key
 
+    @pytest.mark.asyncio
+    async def test_update_chart_preview_form_data_key_optional(self):
+        """Test that form_data_key can be omitted for fresh previews."""
+        config = TableChartConfig(
+            chart_type="table",
+            columns=[ColumnRef(name="col1")],
+        )
+
+        # Omit form_data_key entirely
+        request = UpdateChartPreviewRequest(dataset_id=1, config=config)
+        assert request.form_data_key is None
+
+        # Explicitly pass None
+        request2 = UpdateChartPreviewRequest(
+            form_data_key=None, dataset_id=1, config=config
+        )
+        assert request2.form_data_key is None
+
     @pytest.mark.asyncio
     async def test_update_chart_preview_cache_control(self):
         """Test cache control parameters in update preview request."""
@@ -575,11 +593,13 @@ class TestUpdateChartPreview:
     @patch.object(update_chart_preview_module, "analyze_chart_capabilities")
     @patch.object(update_chart_preview_module, "generate_explore_link")
     @patch.object(update_chart_preview_module, "_get_previous_form_data")
+    @patch.object(update_chart_preview_module, "_find_dataset")
     @patch("superset.mcp_service.auth.get_user_from_request")
     @pytest.mark.asyncio
     async def test_warns_when_previous_form_data_key_is_missing(
         self,
         mock_get_user_from_request,
+        mock_find_dataset,
         mock_get_previous_form_data,
         mock_generate_explore_link,
         mock_analyze_chart_capabilities,
@@ -592,6 +612,7 @@ class TestUpdateChartPreview:
         mock_user = Mock()
         mock_user.id = 1
         mock_get_user_from_request.return_value = mock_user
+        mock_find_dataset.return_value = _mock_dataset(id=3)
         mock_find_by_id.return_value = _mock_dataset(id=3)
         mock_validate_and_compile.return_value = Mock(success=True)
         mock_get_previous_form_data.return_value = None
@@ -636,11 +657,13 @@ class TestUpdateChartPreview:
     @patch.object(update_chart_preview_module, "analyze_chart_capabilities")
     @patch.object(update_chart_preview_module, "generate_explore_link")
     @patch.object(update_chart_preview_module, "_get_previous_form_data")
+    @patch.object(update_chart_preview_module, "_find_dataset")
     @patch("superset.mcp_service.auth.get_user_from_request")
     @pytest.mark.asyncio
     async def test_preserves_previous_adhoc_filters_without_warning(
         self,
         mock_get_user_from_request,
+        mock_find_dataset,
         mock_get_previous_form_data,
         mock_generate_explore_link,
         mock_analyze_chart_capabilities,
@@ -653,6 +676,7 @@ class TestUpdateChartPreview:
         mock_user = Mock()
         mock_user.id = 1
         mock_get_user_from_request.return_value = mock_user
+        mock_find_dataset.return_value = _mock_dataset(id=3)
         mock_find_by_id.return_value = _mock_dataset(id=3)
         mock_validate_and_compile.return_value = Mock(success=True)
         cached_adhoc_filters = [
@@ -707,11 +731,13 @@ class TestUpdateChartPreview:
     @patch.object(update_chart_preview_module, "analyze_chart_capabilities")
     @patch.object(update_chart_preview_module, "generate_explore_link")
     @patch.object(update_chart_preview_module, "_get_previous_form_data")
+    @patch.object(update_chart_preview_module, "_find_dataset")
     @patch("superset.mcp_service.auth.get_user_from_request")
     @pytest.mark.asyncio
     async def test_returns_requested_table_preview(
         self,
         mock_get_user_from_request,
+        mock_find_dataset,
         mock_get_previous_form_data,
         mock_generate_explore_link,
         mock_analyze_chart_capabilities,
@@ -725,6 +751,7 @@ class TestUpdateChartPreview:
         mock_user = Mock()
         mock_user.id = 1
         mock_get_user_from_request.return_value = mock_user
+        mock_find_dataset.return_value = _mock_dataset(id=3)
         mock_find_by_id.return_value = _mock_dataset(id=3)
         mock_validate_and_compile.return_value = Mock(success=True)
         mock_get_previous_form_data.return_value = {}
@@ -773,9 +800,45 @@ class TestUpdateChartPreview:
         assert preview_kwargs["form_data"]["viz_type"] == "table"
 
 
+class TestUpdateChartPreviewTool:
+    """Tests for update_chart_preview tool execution."""
+
+    @patch.object(
+        update_chart_preview_module,
+        "_find_dataset",
+        return_value=None,
+    )
+    @pytest.mark.asyncio
+    async def test_update_chart_preview_dataset_not_found(
+        self, mock_find_dataset, mcp_server, mock_auth
+    ):
+        """Test that a non-existent dataset returns a clear error."""
+
+        request = {
+            "dataset_id": 99999,
+            "config": {
+                "chart_type": "table",
+                "columns": [{"name": "col1"}],
+            },
+        }
+
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "update_chart_preview", {"request": request}
+            )
+
+            data = result.structured_content
+            assert data["success"] is False
+            assert data["chart"] is None
+            error = data["error"]
+            assert error["error_type"] == "dataset_not_found"
+            assert "99999" in error["message"]
+
+
 class TestUpdateChartPreviewValidation:
     """Tier-1 validation gate and dataset access checks."""
 
+    @patch.object(update_chart_preview_module, "_find_dataset")
     @patch.object(update_chart_preview_module, "has_dataset_access", 
return_value=True)
     @patch("superset.daos.dataset.DatasetDAO.find_by_id")
     @patch.object(update_chart_preview_module, "validate_and_compile")
@@ -787,8 +850,9 @@ class TestUpdateChartPreviewValidation:
         self,
         mock_create_form_data,
         mock_validate,
-        mock_find_dataset,
+        mock_find_by_id,
         unused_access_mock,
+        mock_find_dataset,
         mcp_server,
         mock_auth,
     ):
@@ -797,6 +861,7 @@ class TestUpdateChartPreviewValidation:
         from superset.mcp_service.common.error_schemas import 
ChartGenerationError
 
         mock_find_dataset.return_value = _mock_dataset(id=3)
+        mock_find_by_id.return_value = _mock_dataset(id=3)
         mock_validate.return_value = CompileResult(
             success=False,
             error="Column 'num_boys' does not exist in dataset",
@@ -834,6 +899,7 @@ class TestUpdateChartPreviewValidation:
             assert "sum_boys" in error["suggestions"]
             mock_create_form_data.assert_not_called()
 
+    @patch.object(update_chart_preview_module, "_find_dataset")
     @patch.object(update_chart_preview_module, "has_dataset_access", 
return_value=False)
     @patch("superset.daos.dataset.DatasetDAO.find_by_id")
     @patch(
@@ -843,13 +909,15 @@ class TestUpdateChartPreviewValidation:
     async def test_dataset_access_denied_short_circuits(
         self,
         mock_create_form_data,
-        mock_find_dataset,
+        mock_find_by_id,
         unused_access_mock,
+        mock_find_dataset,
         mcp_server,
         mock_auth,
     ):
         """has_dataset_access=False → DatasetNotAccessible, no cache write."""
         mock_find_dataset.return_value = _mock_dataset(id=3)
+        mock_find_by_id.return_value = _mock_dataset(id=3)
 
         config = TableChartConfig(
             chart_type="table", columns=[ColumnRef(name="region")]

Reply via email to