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 eef4d95c229 fix(mcp): add dataset validation for chart tools (#37185)
eef4d95c229 is described below

commit eef4d95c22903329c790bc8c7826d6d25ac7fd31
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed Feb 25 12:54:47 2026 -0500

    fix(mcp): add dataset validation for chart tools (#37185)
    
    Co-authored-by: Claude Opus 4.5 <[email protected]>
---
 superset/mcp_service/CLAUDE.md                     |  37 +++++++
 superset/mcp_service/chart/chart_utils.py          | 120 ++++++++++++++++++++-
 superset/mcp_service/chart/tool/generate_chart.py  |  36 +++++--
 superset/mcp_service/chart/tool/get_chart_data.py  |  22 +++-
 superset/mcp_service/chart/tool/get_chart_info.py  |  20 ++++
 .../mcp_service/chart/tool/get_chart_preview.py    |  82 ++++++++++++--
 .../mcp_service/chart/test_chart_utils.py          | 101 ++++++++++++++++-
 7 files changed, 397 insertions(+), 21 deletions(-)

diff --git a/superset/mcp_service/CLAUDE.md b/superset/mcp_service/CLAUDE.md
index 33d61e30490..f8a3984c6c9 100644
--- a/superset/mcp_service/CLAUDE.md
+++ b/superset/mcp_service/CLAUDE.md
@@ -423,6 +423,43 @@ def my_tool(id: int) -> MyResponse:
         raise ValueError(f"Resource {id} not found")
 ```
 
+### 8. Dataset Validation for Chart Tools
+
+**IMPORTANT**: All chart-related tools must validate that the chart's dataset 
is accessible before performing operations. Use the shared 
`validate_chart_dataset` utility from `chart_utils.py`.
+
+**Why this matters**: Charts can reference datasets that have been deleted or 
become inaccessible. Without validation, users may see confusing errors when 
trying to preview or get data from charts with missing datasets.
+
+**Usage pattern**:
+```python
+from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+
+# After retrieving a chart, validate its dataset
+validation_result = validate_chart_dataset(chart, check_access=True)
+if not validation_result.is_valid:
+    await ctx.warning("Dataset not accessible: %s" % 
(validation_result.error,))
+    return ChartError(
+        error=validation_result.error or "Chart's dataset is not accessible",
+        error_type="DatasetNotAccessible",
+    )
+
+# Log any warnings (e.g., virtual dataset warnings)
+for warning in validation_result.warnings:
+    await ctx.warning("Dataset warning: %s" % (warning,))
+```
+
+**Tools that use this pattern**:
+- `get_chart_info` - Validates after retrieving chart metadata
+- `get_chart_preview` - Validates before generating preview
+- `get_chart_data` - Validates before querying data
+- `generate_chart` - Validates after chart creation (post-creation validation)
+
+**The `DatasetValidationResult` contains**:
+- `is_valid`: Whether the dataset exists and is accessible
+- `dataset_id`: The dataset ID being validated
+- `dataset_name`: The dataset name (if found)
+- `warnings`: List of warnings (e.g., "virtual dataset may be deleted")
+- `error`: Error message if validation failed
+
 ## Testing Conventions
 
 ### Unit Tests
diff --git a/superset/mcp_service/chart/chart_utils.py 
b/superset/mcp_service/chart/chart_utils.py
index cd5b9456063..d798fe34009 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -23,6 +23,7 @@ generation that can be used by both generate_chart and 
generate_explore_link too
 """
 
 import logging
+from dataclasses import dataclass
 from typing import Any, Dict
 
 from superset.mcp_service.chart.schemas import (
@@ -38,6 +39,113 @@ from superset.utils import json
 logger = logging.getLogger(__name__)
 
 
+@dataclass
+class DatasetValidationResult:
+    """Result of dataset accessibility validation."""
+
+    is_valid: bool
+    dataset_id: int | str | None
+    dataset_name: str | None
+    warnings: list[str]
+    error: str | None = None
+
+
+def validate_chart_dataset(
+    chart: Any,
+    check_access: bool = True,
+) -> DatasetValidationResult:
+    """
+    Validate that a chart's dataset exists and is accessible.
+
+    This shared utility should be called by MCP tools after creating or 
retrieving
+    charts to detect issues like missing or deleted datasets early.
+
+    Args:
+        chart: A chart-like object with datasource_id, datasource_type 
attributes
+        check_access: Whether to also check user permissions (default True)
+
+    Returns:
+        DatasetValidationResult with validation status and any warnings
+    """
+    from sqlalchemy.exc import SQLAlchemyError
+
+    from superset.daos.dataset import DatasetDAO
+    from superset.mcp_service.auth import has_dataset_access
+
+    warnings: list[str] = []
+    datasource_id = getattr(chart, "datasource_id", None)
+
+    # Check if chart has a datasource reference
+    if datasource_id is None:
+        return DatasetValidationResult(
+            is_valid=False,
+            dataset_id=None,
+            dataset_name=None,
+            warnings=[],
+            error="Chart has no dataset reference (datasource_id is None)",
+        )
+
+    # Try to look up the dataset
+    try:
+        dataset = DatasetDAO.find_by_id(datasource_id)
+
+        if dataset is None:
+            return DatasetValidationResult(
+                is_valid=False,
+                dataset_id=datasource_id,
+                dataset_name=None,
+                warnings=[],
+                error=(
+                    f"Dataset (ID: {datasource_id}) has been deleted or does 
not "
+                    f"exist. The chart will not render correctly. "
+                    f"Consider updating the chart to use a different dataset."
+                ),
+            )
+
+        dataset_name = getattr(dataset, "table_name", None) or getattr(
+            dataset, "name", None
+        )
+
+        # Check if it's a virtual dataset (SQL Lab query)
+        is_virtual = bool(getattr(dataset, "sql", None))
+        if is_virtual:
+            warnings.append(
+                f"This chart uses a virtual dataset (SQL-based). "
+                f"If the dataset '{dataset_name}' is deleted, this chart will 
break."
+            )
+
+        # Check access permissions if requested
+        if check_access and not has_dataset_access(dataset):
+            return DatasetValidationResult(
+                is_valid=False,
+                dataset_id=datasource_id,
+                dataset_name=dataset_name,
+                warnings=warnings,
+                error=(
+                    f"Access denied to dataset '{dataset_name}' (ID: 
{datasource_id}). "
+                    f"You do not have permission to view this dataset."
+                ),
+            )
+
+        return DatasetValidationResult(
+            is_valid=True,
+            dataset_id=datasource_id,
+            dataset_name=dataset_name,
+            warnings=warnings,
+            error=None,
+        )
+
+    except (AttributeError, ValueError, RuntimeError, SQLAlchemyError) as e:
+        logger.exception("Error validating chart dataset %s: %s", 
datasource_id, e)
+        return DatasetValidationResult(
+            is_valid=False,
+            dataset_id=datasource_id,
+            dataset_name=None,
+            warnings=[],
+            error=f"Error validating dataset (ID: {datasource_id}): {str(e)}",
+        )
+
+
 def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> 
str:
     """Generate an explore link for the given dataset and form data."""
     from sqlalchemy.exc import SQLAlchemyError
@@ -45,6 +153,7 @@ def generate_explore_link(dataset_id: int | str, form_data: 
Dict[str, Any]) -> s
     from superset.commands.exceptions import CommandException
     from superset.commands.explore.form_data.parameters import 
CommandParameters
     from superset.daos.dataset import DatasetDAO
+    from superset.exceptions import SupersetException
     from superset.mcp_service.commands.create_form_data import (
         MCPCreateFormDataCommand,
     )
@@ -95,8 +204,17 @@ def generate_explore_link(dataset_id: int | str, form_data: 
Dict[str, Any]) -> s
         # Return URL with just the form_data_key
         return f"{base_url}/explore/?form_data_key={form_data_key}"
 
-    except (CommandException, SQLAlchemyError, ValueError, AttributeError):
+    except (
+        CommandException,
+        SupersetException,
+        SQLAlchemyError,
+        KeyError,
+        ValueError,
+        AttributeError,
+        TypeError,
+    ) as e:
         # Fallback to basic explore URL with numeric ID if available
+        logger.debug("Explore link generation fallback due to: %s", e)
         if numeric_dataset_id is not None:
             return (
                 f"{base_url}/explore/?datasource_type=table"
diff --git a/superset/mcp_service/chart/tool/generate_chart.py 
b/superset/mcp_service/chart/tool/generate_chart.py
index 913a46bc6cd..dcd4d3825e0 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -25,6 +25,8 @@ from urllib.parse import parse_qs, urlparse
 from fastmcp import Context
 from superset_core.mcp import tool
 
+from superset.commands.exceptions import CommandException
+from superset.exceptions import SupersetException
 from superset.extensions import event_logger
 from superset.mcp_service.auth import has_dataset_access
 from superset.mcp_service.chart.chart_utils import (
@@ -32,6 +34,7 @@ from superset.mcp_service.chart.chart_utils import (
     analyze_chart_semantics,
     generate_chart_name,
     map_config_to_form_data,
+    validate_chart_dataset,
 )
 from superset.mcp_service.chart.schemas import (
     AccessibilityMetadata,
@@ -185,6 +188,7 @@ async def generate_chart(  # noqa: C901
         chart_id = None
         explore_url = None
         form_data_key = None
+        response_warnings: list[str] = []
 
         # Save chart by default (unless save_chart=False)
         if request.save_chart:
@@ -300,7 +304,25 @@ async def generate_chart(  # noqa: C901
                     )
                 )
 
-            except Exception as e:
+                # Post-creation validation: verify the chart's dataset is 
accessible
+                dataset_check = validate_chart_dataset(chart, 
check_access=True)
+                if not dataset_check.is_valid:
+                    # Dataset validation failed - warn but don't fail the 
operation
+                    await ctx.warning(
+                        "Chart created but dataset validation failed: %s"
+                        % (dataset_check.error,)
+                    )
+                    logger.warning(
+                        "Chart %s created but dataset validation failed: %s",
+                        chart.id,
+                        dataset_check.error,
+                    )
+                    if dataset_check.error:
+                        response_warnings.append(dataset_check.error)
+                # Add any validation warnings (e.g., virtual dataset warnings)
+                response_warnings.extend(dataset_check.warnings)
+
+            except CommandException as e:
                 logger.error("Chart creation failed: %s", e)
                 await ctx.error("Chart creation failed: error=%s" % (str(e),))
                 raise
@@ -338,7 +360,7 @@ async def generate_chart(  # noqa: C901
                         "Generated form_data_key for saved chart: "
                         "form_data_key=%s" % (form_data_key,)
                     )
-            except Exception as fdk_error:
+            except CommandException as fdk_error:
                 logger.warning(
                     "Failed to generate form_data_key for saved chart: %s",
                     fdk_error,
@@ -456,7 +478,7 @@ async def generate_chart(  # noqa: C901
                                 if not hasattr(preview_result, "error"):
                                     previews[format_type] = preview_result
 
-            except Exception as e:
+            except (CommandException, ValueError, KeyError) as e:
                 # Log warning but don't fail the entire request
                 await ctx.warning("Preview generation failed: error=%s" % 
(str(e),))
                 logger.warning("Preview generation failed: %s", e)
@@ -507,8 +529,8 @@ async def generate_chart(  # noqa: C901
             else {},
             "performance": performance.model_dump() if performance else None,
             "accessibility": accessibility.model_dump() if accessibility else 
None,
-            # Runtime warnings (informational suggestions, not errors)
-            "warnings": runtime_warnings,
+            # Combined runtime and response warnings
+            "warnings": runtime_warnings + response_warnings,
             "success": True,
             "schema_version": "2.0",
             "api_version": "v1",
@@ -523,7 +545,7 @@ async def generate_chart(  # noqa: C901
         )
         return GenerateChartResponse.model_validate(result)
 
-    except Exception as e:
+    except (CommandException, SupersetException, ValueError, KeyError) as e:
         await ctx.error(
             "Chart generation failed: error=%s, execution_time_ms=%s"
             % (
@@ -540,7 +562,7 @@ async def generate_chart(  # noqa: C901
         try:
             if hasattr(request, "config") and hasattr(request.config, 
"chart_type"):
                 chart_type = request.config.chart_type
-        except Exception as extract_error:
+        except AttributeError as extract_error:
             # Ignore errors when extracting chart type for error context
             logger.debug("Could not extract chart type: %s", extract_error)
 
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py 
b/superset/mcp_service/chart/tool/get_chart_data.py
index a9970814b34..859d09e7331 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -32,7 +32,9 @@ if TYPE_CHECKING:
 
 from superset.commands.exceptions import CommandException
 from superset.commands.explore.form_data.parameters import CommandParameters
+from superset.exceptions import SupersetException
 from superset.extensions import event_logger
+from superset.mcp_service.chart.chart_utils import validate_chart_dataset
 from superset.mcp_service.chart.schemas import (
     ChartData,
     ChartError,
@@ -147,6 +149,22 @@ async def get_chart_data(  # noqa: C901
         )
         logger.info("Getting data for chart %s: %s", chart.id, 
chart.slice_name)
 
+        # Validate the chart's dataset is accessible before retrieving data
+        validation_result = validate_chart_dataset(chart, check_access=True)
+        if not validation_result.is_valid:
+            await ctx.warning(
+                "Chart found but dataset is not accessible: %s"
+                % (validation_result.error,)
+            )
+            return ChartError(
+                error=validation_result.error
+                or "Chart's dataset is not accessible. Dataset may have been 
deleted.",
+                error_type="DatasetNotAccessible",
+            )
+        # Log any warnings (e.g., virtual dataset warnings)
+        for warning in validation_result.warnings:
+            await ctx.warning("Dataset warning: %s" % (warning,))
+
         start_time = time.time()
 
         # Track whether we're using unsaved state
@@ -658,7 +676,7 @@ async def get_chart_data(  # noqa: C901
                 cache_status=cache_status,
             )
 
-        except Exception as data_error:
+        except (CommandException, SupersetException, ValueError) as data_error:
             await ctx.error(
                 "Data retrieval failed: chart_id=%s, error=%s, error_type=%s"
                 % (
@@ -673,7 +691,7 @@ async def get_chart_data(  # noqa: C901
                 error_type="DataError",
             )
 
-    except Exception as e:
+    except (CommandException, SupersetException, ValueError, KeyError) as e:
         await ctx.error(
             "Chart data retrieval failed: identifier=%s, error=%s, 
error_type=%s"
             % (
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py 
b/superset/mcp_service/chart/tool/get_chart_info.py
index d3d92967408..3b86dcc8675 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -28,6 +28,7 @@ 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.chart_utils import validate_chart_dataset
 from superset.mcp_service.chart.schemas import (
     ChartError,
     ChartInfo,
@@ -158,6 +159,25 @@ async def get_chart_info(
             "Chart information retrieved successfully: chart_name=%s, "
             "is_unsaved_state=%s" % (result.slice_name, 
result.is_unsaved_state)
         )
+
+        # Validate the chart's dataset is accessible
+        if result.id:
+            chart = ChartDAO.find_by_id(result.id)
+            if chart:
+                validation_result = validate_chart_dataset(chart, 
check_access=True)
+                if not validation_result.is_valid:
+                    await ctx.warning(
+                        "Chart found but dataset is not accessible: %s"
+                        % (validation_result.error,)
+                    )
+                    return ChartError(
+                        error=validation_result.error
+                        or "Chart's dataset is not accessible",
+                        error_type="DatasetNotAccessible",
+                    )
+                # Log any warnings (e.g., virtual dataset warnings)
+                for warning in validation_result.warnings:
+                    await ctx.warning("Dataset warning: %s" % (warning,))
     else:
         await ctx.warning("Chart retrieval failed: error=%s" % (str(result),))
 
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py 
b/superset/mcp_service/chart/tool/get_chart_preview.py
index 606ee88b023..48ad087fd3e 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -25,7 +25,10 @@ from typing import Any, Dict, List, Protocol
 from fastmcp import Context
 from superset_core.mcp import tool
 
+from superset.commands.exceptions import CommandException
+from superset.exceptions import SupersetException
 from superset.extensions import event_logger
+from superset.mcp_service.chart.chart_utils import validate_chart_dataset
 from superset.mcp_service.chart.schemas import (
     AccessibilityMetadata,
     ASCIIPreview,
@@ -176,7 +179,14 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
                 height=self.request.ascii_height or 20,
             )
 
-        except Exception as e:
+        except (
+            CommandException,
+            SupersetException,
+            ValueError,
+            KeyError,
+            AttributeError,
+            TypeError,
+        ) as e:
             logger.error("ASCII preview generation failed: %s", e)
             return ChartError(
                 error=f"Failed to generate ASCII preview: {str(e)}",
@@ -237,7 +247,14 @@ class TablePreviewStrategy(PreviewFormatStrategy):
                 row_count=len(data),
             )
 
-        except Exception as e:
+        except (
+            CommandException,
+            SupersetException,
+            ValueError,
+            KeyError,
+            AttributeError,
+            TypeError,
+        ) as e:
             logger.error("Table preview generation failed: %s", e)
             return ChartError(
                 error=f"Failed to generate table preview: {str(e)}",
@@ -256,7 +273,7 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
 
                 return utils_json.loads(self.chart.params)
             return None
-        except Exception:
+        except (ValueError, TypeError):
             return None
 
     def generate(self) -> VegaLitePreview | ChartError:
@@ -345,7 +362,14 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
                 supports_streaming=False,
             )
 
-        except Exception as e:
+        except (
+            CommandException,
+            SupersetException,
+            ValueError,
+            KeyError,
+            AttributeError,
+            TypeError,
+        ) as e:
             logger.exception(
                 "Error generating Vega-Lite preview for chart %s", 
self.chart.id
             )
@@ -455,11 +479,11 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
                     field_type = self._determine_field_type(sample_values)
                     field_types[field] = field_type
 
-                except Exception as e:
+                except (TypeError, ValueError, KeyError, AttributeError) as e:
                     logger.warning("Error analyzing field '%s': %s", field, e)
                     field_types[field] = "nominal"  # Safe default
 
-        except Exception as e:
+        except (TypeError, ValueError, KeyError, AttributeError) as e:
             logger.warning("Error in field type analysis: %s", e)
             # Return nominal types for all fields as fallback
             return dict.fromkeys(fields, "nominal")
@@ -945,7 +969,7 @@ def generate_ascii_chart(
                 "Unsupported chart type '%s', falling back to table", 
chart_type
             )
             return _generate_ascii_table(data, width)
-    except Exception as e:
+    except (TypeError, ValueError, KeyError, IndexError) as e:
         logger.error("ASCII chart generation failed: %s", e)
         import traceback
 
@@ -1866,7 +1890,13 @@ async def _get_chart_preview_internal(  # noqa: C901
                                     self.uuid = None
 
                             chart = TransientChart(form_data)
-                    except (ValueError, KeyError, AttributeError, TypeError) 
as e:
+                    except (
+                        CommandException,
+                        ValueError,
+                        KeyError,
+                        AttributeError,
+                        TypeError,
+                    ) as e:
                         # Form data key not found or invalid
                         logger.debug(
                             "Failed to get form data for key %s: %s",
@@ -1900,6 +1930,24 @@ async def _get_chart_preview_internal(  # noqa: C901
         logger.info("Generating preview for chart %s", getattr(chart, "id", 
"NO_ID"))
         logger.info("Chart datasource_id: %s", getattr(chart, "datasource_id", 
"NONE"))
 
+        # Validate the chart's dataset is accessible before generating preview
+        # Skip validation for transient charts (no ID) - different data sources
+        if getattr(chart, "id", None) is not None:
+            validation_result = validate_chart_dataset(chart, 
check_access=True)
+            if not validation_result.is_valid:
+                await ctx.warning(
+                    "Chart found but dataset is not accessible: %s"
+                    % (validation_result.error,)
+                )
+                return ChartError(
+                    error=validation_result.error
+                    or "Chart's dataset is not accessible. Dataset may be 
deleted.",
+                    error_type="DatasetNotAccessible",
+                )
+            # Log any warnings (e.g., virtual dataset warnings)
+            for warning in validation_result.warnings:
+                await ctx.warning("Dataset warning: %s" % (warning,))
+
         import time
 
         start_time = time.time()
@@ -1990,7 +2038,14 @@ async def _get_chart_preview_internal(  # noqa: C901
 
         return result
 
-    except Exception as e:
+    except (
+        CommandException,
+        SupersetException,
+        ValueError,
+        KeyError,
+        AttributeError,
+        TypeError,
+    ) as e:
         await ctx.error(
             "Chart preview generation failed: identifier=%s, format=%s, 
error=%s, "
             "error_type=%s"
@@ -2055,7 +2110,14 @@ async def get_chart_preview(
             )
 
         return result
-    except Exception as e:
+    except (
+        CommandException,
+        SupersetException,
+        ValueError,
+        KeyError,
+        AttributeError,
+        TypeError,
+    ) as e:
         await ctx.error(
             "Chart preview generation failed: identifier=%s, error=%s, 
error_type=%s"
             % (
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py 
b/tests/unit_tests/mcp_service/chart/test_chart_utils.py
index d1b8696f3a4..c142aabb8ba 100644
--- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py
@@ -18,7 +18,7 @@
 """Tests for chart utilities module"""
 
 from typing import Any
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
 
 import pytest
 
@@ -32,6 +32,7 @@ from superset.mcp_service.chart.chart_utils import (
     map_filter_operator,
     map_table_config,
     map_xy_config,
+    validate_chart_dataset,
 )
 from superset.mcp_service.chart.schemas import (
     AxisConfig,
@@ -1045,3 +1046,101 @@ class TestFilterConfigValidation:
         """Test IN operator with empty list"""
         f = FilterConfig(column="platform", op="IN", value=[])
         assert f.value == []
+
+
+class TestValidateChartDataset:
+    """Test validate_chart_dataset function"""
+
+    @patch("superset.mcp_service.auth.has_dataset_access")
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_validate_chart_dataset_no_datasource_id(
+        self, mock_find: MagicMock, mock_access: MagicMock
+    ) -> None:
+        """Chart with no datasource_id returns invalid result."""
+        chart = MagicMock(spec=[])  # no datasource_id attribute
+        result = validate_chart_dataset(chart)
+        assert not result.is_valid
+        assert result.dataset_id is None
+        assert "no dataset reference" in (result.error or "").lower()
+        mock_find.assert_not_called()
+
+    @patch("superset.mcp_service.auth.has_dataset_access")
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=None)
+    def test_validate_chart_dataset_deleted_dataset(
+        self, mock_find: MagicMock, mock_access: MagicMock
+    ) -> None:
+        """Chart whose dataset was deleted returns invalid result."""
+        chart = MagicMock()
+        chart.datasource_id = 42
+        result = validate_chart_dataset(chart)
+        assert not result.is_valid
+        assert result.dataset_id == 42
+        assert "deleted" in (result.error or "").lower()
+
+    @patch("superset.mcp_service.auth.has_dataset_access", return_value=True)
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_validate_chart_dataset_valid(
+        self, mock_find: MagicMock, mock_access: MagicMock
+    ) -> None:
+        """Valid chart with accessible dataset returns valid result."""
+        dataset = MagicMock()
+        dataset.table_name = "my_table"
+        dataset.sql = None
+        mock_find.return_value = dataset
+        chart = MagicMock()
+        chart.datasource_id = 7
+        result = validate_chart_dataset(chart)
+        assert result.is_valid
+        assert result.dataset_id == 7
+        assert result.dataset_name == "my_table"
+        assert result.warnings == []
+
+    @patch("superset.mcp_service.auth.has_dataset_access", return_value=True)
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_validate_chart_dataset_virtual_warns(
+        self, mock_find: MagicMock, mock_access: MagicMock
+    ) -> None:
+        """Virtual dataset emits a warning."""
+        dataset = MagicMock()
+        dataset.table_name = "virt_ds"
+        dataset.sql = "SELECT 1"
+        mock_find.return_value = dataset
+        chart = MagicMock()
+        chart.datasource_id = 10
+        result = validate_chart_dataset(chart)
+        assert result.is_valid
+        assert len(result.warnings) == 1
+        assert "virtual" in result.warnings[0].lower()
+
+    @patch("superset.mcp_service.auth.has_dataset_access")
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_validate_chart_dataset_sqlalchemy_error(
+        self, mock_find: MagicMock, mock_access: MagicMock
+    ) -> None:
+        """SQLAlchemy errors are caught and produce an invalid result."""
+        from sqlalchemy.exc import SQLAlchemyError
+
+        mock_find.side_effect = SQLAlchemyError("connection lost")
+        chart = MagicMock()
+        chart.datasource_id = 99
+        result = validate_chart_dataset(chart)
+        assert not result.is_valid
+        assert result.dataset_id == 99
+        assert "error" in (result.error or "").lower()
+
+    @patch(
+        "superset.mcp_service.chart.chart_utils.get_superset_base_url",
+        return_value="http://localhost:8088";,
+    )
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_generate_explore_link_sqlalchemy_error(
+        self,
+        mock_find: MagicMock,
+        mock_base_url: MagicMock,
+    ) -> None:
+        """SQLAlchemy errors in generate_explore_link fall back to basic 
URL."""
+        from sqlalchemy.exc import SQLAlchemyError
+
+        mock_find.side_effect = SQLAlchemyError("db gone")
+        url = generate_explore_link(5, {"viz_type": "table"})
+        assert "datasource_id=5" in url

Reply via email to