This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 e229ea4a543 fix(mcp): resolve saved semantic-view chart sources by
type (#45067)
e229ea4a543 is described below
commit e229ea4a543546b94bc3e751f611109d111cc8db
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 17:55:54 2026 -0600
fix(mcp): resolve saved semantic-view chart sources by type (#45067)
Co-authored-by: Mike Bridge <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
docs/admin_docs/configuration/mcp-server.mdx | 8 +-
superset/mcp_service/auth.py | 13 +-
superset/mcp_service/chart/chart_utils.py | 47 ++-
superset/mcp_service/chart/tool/get_chart_data.py | 17 +-
superset/mcp_service/chart/tool/get_chart_info.py | 12 +-
superset/mcp_service/chart/tool/update_chart.py | 26 +-
.../chart/tool/test_saved_chart_datasource.py | 422 +++++++++++++++++++++
7 files changed, 531 insertions(+), 14 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx
b/docs/admin_docs/configuration/mcp-server.mdx
index 0ad3774e7f9..3ab2485cb2f 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1413,8 +1413,12 @@ Both fields are redacted for users without data-model
metadata access.
Chart and dashboard chart summaries resolve dataset names from the live table
or semantic view, so renames are reflected without resaving the chart. Query
and
saved-query charts retain their stored datasource names; missing tables or
-semantic views do not fall back to stale names. Dataset-access validation
remains
-unchanged.
+semantic views do not fall back to stale names. Saved-chart `get_chart_data`
+and `get_chart_info` prechecks, and the chart access checks in
+`add_chart_to_existing_dashboard`, `generate_dashboard`, and `update_chart`,
+resolve semantic-view sources by type and ID, then check access before
+continuing. `update_chart` then refuses semantic-view charts, which it cannot
+yet edit. SQL-dataset charts retain their existing dataset validation.
`list_charts` filters on `datasource_name` use these same live names before
pagination and counting. For tables with a schema, filter using
diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py
index a4ee569cf54..746e9044cb4 100644
--- a/superset/mcp_service/auth.py
+++ b/superset/mcp_service/auth.py
@@ -949,14 +949,23 @@ def check_chart_data_access(chart: Any) ->
"DatasetValidationResult":
permissions before tool execution; this function enforces data-level
permissions inside tools after retrieving specific objects.
+ Semantic views share the numeric id space with datasets, so the source is
+ resolved by ``datasource_type``, never by id alone.
+
Args:
- chart: A Slice ORM object with datasource_id attribute
+ chart: A Slice ORM object with datasource_id and datasource_type
Returns:
DatasetValidationResult with is_valid, error, etc.
"""
- from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+ from superset.mcp_service.chart.chart_utils import (
+ validate_chart_dataset,
+ validate_chart_semantic_view,
+ )
+ from superset.utils.core import DatasourceType
+ if getattr(chart, "datasource_type", None) ==
DatasourceType.SEMANTIC_VIEW.value:
+ return validate_chart_semantic_view(chart.datasource_id,
check_access=True)
return validate_chart_dataset(chart.datasource_id, check_access=True)
diff --git a/superset/mcp_service/chart/chart_utils.py
b/superset/mcp_service/chart/chart_utils.py
index 0ed9852e7f4..da9b7f1bf4f 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -26,15 +26,18 @@ import hashlib
import logging
from collections.abc import Mapping
from dataclasses import dataclass
-from typing import Any, Dict, TYPE_CHECKING
+from typing import Any, cast, Dict, TYPE_CHECKING
from pydantic import TypeAdapter, ValidationError
from sqlalchemy.exc import SQLAlchemyError
if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable
+ from superset.semantic_layers.models import SemanticView
from superset.constants import NO_TIME_RANGE
+from superset.daos.exceptions import DatasourceNotFound,
DatasourceValueIsIncorrect
+from superset.exceptions import SupersetSecurityException
from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
BoxPlotChartConfig,
@@ -68,7 +71,7 @@ from superset.mcp_service.chart.validation.dataset_validator
import (
from superset.mcp_service.common.error_schemas import DatasetContext
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
-from superset.utils.core import FilterOperator
+from superset.utils.core import DatasourceType, FilterOperator
logger = logging.getLogger(__name__)
@@ -89,6 +92,46 @@ class DatasetValidationResult:
error: str | None = None
+def validate_chart_semantic_view(
+ datasource_id: int | None,
+ check_access: bool = True,
+) -> DatasetValidationResult:
+ """Resolve and authorize a saved semantic source before ordinary chart
reads.
+
+ With ``check_access=False`` (embedded guests) only existence is checked: a
+ guest is authorized later by the dashboard-scoped query check, which also
+ refuses guest row-level security on semantic views. Callers passing False
+ must run that check (``ChartDataCommand.validate()``) before executing.
+ """
+ # Avoid app-init regression: DatasourceDAO imports Database encrypted
columns.
+ from superset.daos.datasource import DatasourceDAO
+
+ if datasource_id is not None:
+ try:
+ view: SemanticView = cast(
+ "SemanticView",
+ DatasourceDAO.get_datasource(
+ DatasourceType.SEMANTIC_VIEW, datasource_id
+ ),
+ )
+ if check_access:
+ view.raise_for_access()
+ return DatasetValidationResult(True, datasource_id, view.name, [])
+ except (
+ DatasourceNotFound,
+ DatasourceValueIsIncorrect,
+ SupersetSecurityException,
+ ):
+ pass
+ return DatasetValidationResult(
+ False,
+ datasource_id,
+ None,
+ [],
+ error="Semantic view not found or not accessible.",
+ )
+
+
def validate_chart_dataset(
datasource_id: int | None,
check_access: bool = True,
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py
b/superset/mcp_service/chart/tool/get_chart_data.py
index ded6b7f08ee..38ae16bdf96 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -57,7 +57,11 @@ from superset.mcp_service.chart.chart_helpers import (
rejected_requested_filter_columns,
resolve_form_data_datasource,
)
-from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.chart_utils import (
+ DatasetValidationResult,
+ validate_chart_dataset,
+ validate_chart_semantic_view,
+)
from superset.mcp_service.chart.query_result import (
normalize_chart_query_result,
null_data_is_empty,
@@ -86,7 +90,7 @@ from superset.mcp_service.utils.response_utils import (
format_data_columns,
format_data_quality,
)
-from superset.utils.core import GenericDataType
+from superset.utils.core import DatasourceType, GenericDataType
from superset.utils.json import JSONDecodeError
logger = logging.getLogger(__name__)
@@ -552,10 +556,13 @@ async def _get_chart_data( # noqa: C901
logger.info("Getting data for chart %s: %s", chart_id, chart_name)
# Guests skip the RBAC check (authorize_query covers it) but keep the
- # existence check, so a deleted dataset still returns
+ # existence check, so a deleted dataset or semantic view still returns
# DatasetNotAccessible.
- validation_result = validate_chart_dataset(
- chart_datasource_id, check_access=not guest_scope.is_guest_read()
+ check_access: bool = not guest_scope.is_guest_read()
+ validation_result: DatasetValidationResult = (
+ validate_chart_semantic_view(chart_datasource_id,
check_access=check_access)
+ if chart_datasource_type == DatasourceType.SEMANTIC_VIEW.value
+ else validate_chart_dataset(chart_datasource_id,
check_access=check_access)
)
if not validation_result.is_valid:
await ctx.warning(
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py
b/superset/mcp_service/chart/tool/get_chart_info.py
index d3503294378..1f94526fe60 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -43,7 +43,11 @@ from superset.mcp_service.chart.chart_helpers import (
ChartNotOnDashboardError,
get_cached_form_data,
)
-from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.chart_utils import (
+ DatasetValidationResult,
+ validate_chart_dataset,
+ validate_chart_semantic_view,
+)
from superset.mcp_service.chart.schemas import (
ChartError,
ChartFiltersInfo,
@@ -241,7 +245,11 @@ async def _validate_chart_dataset_access(
chart = ChartDAO.find_by_id(result.id)
if not chart:
return None
- validation_result = validate_chart_dataset(chart.datasource_id,
check_access=True)
+ validation_result: DatasetValidationResult = (
+ validate_chart_semantic_view(chart.datasource_id)
+ if chart.datasource_type == DatasourceType.SEMANTIC_VIEW.value
+ else validate_chart_dataset(chart.datasource_id, check_access=True)
+ )
if not validation_result.is_valid:
await ctx.warning(
"Chart found but dataset is not accessible: %s" %
(validation_result.error,)
diff --git a/superset/mcp_service/chart/tool/update_chart.py
b/superset/mcp_service/chart/tool/update_chart.py
index 2be172c02d3..eb94e20dd54 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -70,6 +70,7 @@ from superset.mcp_service.utils.oauth2_utils import (
)
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
+from superset.utils.core import DatasourceType
logger = logging.getLogger(__name__)
@@ -607,7 +608,6 @@ def _create_preview_url(
"""
from superset.commands.explore.form_data.parameters import
CommandParameters
from superset.mcp_service.commands.create_form_data import
MCPCreateFormDataCommand
- from superset.utils.core import DatasourceType
base_url = get_superset_base_url()
@@ -787,6 +787,30 @@ async def update_chart( # noqa: C901
}
)
+ if (
+ getattr(chart, "datasource_type", None)
+ == DatasourceType.SEMANTIC_VIEW.value
+ ):
+ # The update path builds table-typed form data from datasource_id,
+ # which would rebind the chart to a same-id table.
+ unsupported_msg: str = (
+ f"Chart {chart.id} uses a semantic view; update_chart supports
"
+ "only dataset-backed charts."
+ )
+ return GenerateChartResponse.model_validate(
+ {
+ "chart": None,
+ "error": {
+ "error_type": "UnsupportedDatasourceType",
+ "message": unsupported_msg,
+ "details": unsupported_msg,
+ },
+ "success": False,
+ "schema_version": "2.0",
+ "api_version": "v1",
+ }
+ )
+
updated_chart: Any = None
explore_url: str
form_data_key: str | None = None
diff --git
a/tests/unit_tests/mcp_service/chart/tool/test_saved_chart_datasource.py
b/tests/unit_tests/mcp_service/chart/tool/test_saved_chart_datasource.py
new file mode 100644
index 00000000000..90cb1b64f6f
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/tool/test_saved_chart_datasource.py
@@ -0,0 +1,422 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Saved-chart reads resolve semantic and SQL sources without changing query
work."""
+
+import importlib
+from contextlib import nullcontext
+from types import ModuleType, SimpleNamespace
+from typing import Any
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+from fastmcp.client.client import CallToolResult
+from mcp.types import TextContent
+
+from superset.daos.exceptions import DatasourceNotFound
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetSecurityException
+from superset.mcp_service import guest_scope
+from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.chart_utils import DatasetValidationResult
+from superset.mcp_service.chart.schemas import ChartInfo
+from superset.semantic_layers.models import SemanticView
+from superset.utils import json
+from superset.utils.core import DatasourceType
+
+
[email protected]
[email protected]("tool_name", ["get_chart_data", "get_chart_info"])
[email protected](
+ "source_type, outcome",
+ [
+ ("semantic_view", "allowed"),
+ ("table", "allowed"),
+ ("semantic_view", "missing"),
+ ("semantic_view", "denied"),
+ ],
+)
+async def test_saved_chart_reads_use_the_source_type(
+ tool_name: str, source_type: str, outcome: str
+) -> None:
+ """A saved semantic chart must pass the same read boundary as a SQL
chart."""
+ data_module: ModuleType = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_data"
+ )
+ info_module: ModuleType = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_info"
+ )
+ chart: SimpleNamespace = SimpleNamespace(
+ id=9,
+ slice_name="Sales",
+ viz_type="table",
+ datasource_id=17,
+ datasource_type=source_type,
+ params=json.dumps({"viz_type": "table", "datasource":
f"17__{source_type}"}),
+ query_context=json.dumps(
+ {"datasource": {"id": 17, "type": source_type}, "queries": []}
+ ),
+ )
+ info: ChartInfo = ChartInfo(
+ id=9,
+ slice_name="Sales",
+ viz_type="table",
+ datasource_id=17,
+ datasource_type=source_type,
+ )
+ view: SemanticView = SemanticView(id=17, name="Sales")
+ dataset: SimpleNamespace = SimpleNamespace(id=17, table_name="Sales",
sql=None)
+ query_context: SimpleNamespace = SimpleNamespace(queries=[], form_data={})
+ command: Mock = Mock()
+ command.run.return_value = {
+ "queries": [
+ {
+ "data": [{"sales": 7}],
+ "colnames": ["sales"],
+ "coltypes": [0],
+ "rowcount": 1,
+ }
+ ]
+ }
+ table_lookup: Mock
+ view_lookup: Mock
+ view_access: Mock
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin"),
+ ),
+ patch("superset.mcp_service.auth.check_tool_permission",
return_value=True),
+ patch("superset.mcp_service.auth.has_dataset_access",
return_value=True),
+ patch("superset.mcp_service.guest_scope.is_guest_read",
return_value=False),
+ patch("superset.mcp_service.guest_scope.guest_dashboard_id",
return_value=None),
+ patch.object(
+ data_module.event_logger, "log_context", return_value=nullcontext()
+ ),
+ patch.object(data_module, "find_chart_by_identifier",
return_value=chart),
+ patch.object(info_module.ModelGetInfoCore, "run_tool",
return_value=info),
+ patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset if source_type == "table" else None,
+ ) as table_lookup,
+ patch(
+ "superset.daos.datasource.DatasourceDAO.get_datasource",
+ return_value=view,
+ side_effect=DatasourceNotFound() if outcome == "missing" else None,
+ ) as view_lookup,
+ patch.object(
+ SemanticView,
+ "raise_for_access",
+ side_effect=SupersetSecurityException(
+ SupersetError(
+ message="private source detail",
+
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ )
+ if outcome == "denied"
+ else None,
+ ) as view_access,
+ patch(
+ "superset.charts.schemas.ChartDataQueryContextSchema.load",
+ return_value=query_context,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ async with Client(mcp) as client:
+ response: CallToolResult = await client.call_tool(
+ tool_name, {"request": {"identifier": "9"}}
+ )
+ assert isinstance(response.content[0], TextContent)
+ result: dict[str, Any] = json.loads(response.content[0].text)
+ if outcome != "allowed":
+ assert result["error_type"] == "DatasetNotAccessible"
+ assert "private source detail" not in result["error"]
+ command.run.assert_not_called()
+ view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+ table_lookup.assert_not_called()
+ if outcome == "missing":
+ view_access.assert_not_called()
+ else:
+ view_access.assert_called_once()
+ return
+ assert "error_type" not in result, result
+ if tool_name == "get_chart_data":
+ assert result["data"] == [{"sales": 7}]
+ command.validate.assert_called_once()
+ else:
+ assert result["slice_name"] == "Sales"
+ command.run.assert_not_called()
+ if source_type == "semantic_view":
+ view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+ view_access.assert_called_once()
+ table_lookup.assert_not_called()
+ else:
+ table_lookup.assert_called_once_with(17, skip_base_filter=False)
+ view_lookup.assert_not_called()
+
+
[email protected]
[email protected]("outcome", ["allowed", "rls_refused", "missing"])
+async def test_guest_semantic_chart_data_uses_dashboard_authorization(
+ outcome: str,
+) -> None:
+ """A guest reads a semantic chart through the dashboard-scoped query check,
+ like a table chart, instead of the semantic view's direct grants."""
+ data_module: ModuleType = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_data"
+ )
+ chart: SimpleNamespace = SimpleNamespace(
+ id=9,
+ slice_name="Sales",
+ viz_type="table",
+ datasource_id=17,
+ datasource_type="semantic_view",
+ params=json.dumps({"viz_type": "table", "datasource":
"17__semantic_view"}),
+ query_context=json.dumps(
+ {"datasource": {"id": 17, "type": "semantic_view"}, "queries": []}
+ ),
+ )
+ view: SemanticView = SemanticView(id=17, name="Sales")
+ query_context: SimpleNamespace = SimpleNamespace(queries=[], form_data={})
+ command: Mock = Mock()
+ command.run.return_value = {
+ "queries": [
+ {
+ "data": [{"sales": 7}],
+ "colnames": ["sales"],
+ "coltypes": [0],
+ "rowcount": 1,
+ }
+ ]
+ }
+ # The query-context check refuses guest row-level security on semantic
+ # views (raise_for_unsupported_guest_rls via command.validate()).
+ refusal: SupersetSecurityException = SupersetSecurityException(
+ SupersetError(
+ message="Semantic views cannot enforce guest row-level security
rules.",
+ error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+ level=ErrorLevel.WARNING,
+ )
+ )
+ if outcome == "rls_refused":
+ command.validate.side_effect = refusal
+ view_lookup: Mock
+ view_access: Mock
+ authorize_query: Mock
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=None, username="guest"),
+ ),
+ patch("superset.mcp_service.auth.check_tool_permission",
return_value=True),
+ patch("superset.mcp_service.guest_scope.is_guest_read",
return_value=True),
+ patch("superset.mcp_service.guest_scope.guest_dashboard_id",
return_value=5),
+ patch(
+ "superset.mcp_service.guest_scope.authorize_query",
+ wraps=guest_scope.authorize_query,
+ ) as authorize_query,
+ patch.object(
+ data_module.event_logger, "log_context", return_value=nullcontext()
+ ),
+ patch.object(data_module, "find_chart_by_identifier",
return_value=chart),
+ patch(
+ "superset.daos.datasource.DatasourceDAO.get_datasource",
+ return_value=view,
+ side_effect=DatasourceNotFound() if outcome == "missing" else None,
+ ) as view_lookup,
+ patch.object(
+ SemanticView,
+ "raise_for_access",
+ side_effect=SupersetSecurityException(
+ SupersetError(
+ message="no direct grant",
+
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ ),
+ ) as view_access,
+ patch(
+ "superset.charts.schemas.ChartDataQueryContextSchema.load",
+ return_value=query_context,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ async with Client(mcp) as client:
+ response: CallToolResult = await client.call_tool(
+ "get_chart_data", {"request": {"identifier": "9"}}
+ )
+ assert isinstance(response.content[0], TextContent)
+ result: dict[str, Any] = json.loads(response.content[0].text)
+ view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+ view_access.assert_not_called()
+ if outcome == "missing":
+ assert result["error_type"] == "DatasetNotAccessible"
+ authorize_query.assert_not_called()
+ command.run.assert_not_called()
+ return
+ authorize_query.assert_called_once()
+ assert query_context.form_data == {"dashboardId": 5, "slice_id": 9}
+ command.validate.assert_called_once()
+ if outcome == "rls_refused":
+ assert "error_type" in result, result
+ command.run.assert_not_called()
+ return
+ assert "error_type" not in result, result
+ assert result["data"] == [{"sales": 7}]
+
+
[email protected](
+ "source_type, outcome",
+ [
+ ("semantic_view", "allowed"),
+ ("semantic_view", "missing"),
+ ("semantic_view", "denied"),
+ ("table", "allowed"),
+ ],
+)
+def test_chart_data_access_check_uses_the_source_type(
+ source_type: str, outcome: str
+) -> None:
+ """The shared check behind add_chart_to_existing_dashboard,
generate_dashboard
+ and update_chart must not authorize a semantic chart against a same-id
table."""
+ from superset.mcp_service.auth import check_chart_data_access
+
+ chart: SimpleNamespace = SimpleNamespace(
+ id=9, datasource_id=17, datasource_type=source_type
+ )
+ view: SemanticView = SemanticView(id=17, name="Sales")
+ dataset: SimpleNamespace = SimpleNamespace(id=17, table_name="Sales",
sql=None)
+ table_lookup: Mock
+ view_lookup: Mock
+ view_access: Mock
+ with (
+ patch("superset.mcp_service.auth.has_dataset_access",
return_value=True),
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset
+ ) as table_lookup,
+ patch(
+ "superset.daos.datasource.DatasourceDAO.get_datasource",
+ return_value=view,
+ side_effect=DatasourceNotFound() if outcome == "missing" else None,
+ ) as view_lookup,
+ patch.object(
+ SemanticView,
+ "raise_for_access",
+ side_effect=SupersetSecurityException(
+ SupersetError(
+ message="private source detail",
+
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ )
+ if outcome == "denied"
+ else None,
+ ) as view_access,
+ ):
+ result: DatasetValidationResult = check_chart_data_access(chart)
+ assert result.is_valid is (outcome == "allowed")
+ if source_type == "table":
+ table_lookup.assert_called_once_with(17, skip_base_filter=False)
+ view_lookup.assert_not_called()
+ return
+ table_lookup.assert_not_called()
+ view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+ if outcome == "missing":
+ view_access.assert_not_called()
+ else:
+ view_access.assert_called_once()
+ if outcome != "allowed":
+ assert result.error is not None
+ assert "private source detail" not in result.error
+
+
[email protected]
[email protected]("outcome", ["allowed", "denied"])
+async def test_update_chart_refuses_saved_semantic_charts(outcome: str) ->
None:
+ """update_chart builds table-typed form data, so an authorized semantic
chart
+ is refused rather than rebound to a same-id table; denial stays uniform."""
+ update_module: ModuleType = importlib.import_module(
+ "superset.mcp_service.chart.tool.update_chart"
+ )
+ chart: SimpleNamespace = SimpleNamespace(
+ id=9,
+ slice_name="Sales",
+ viz_type="table",
+ datasource_id=17,
+ datasource_type="semantic_view",
+ params=json.dumps({"viz_type": "table", "datasource":
"17__semantic_view"}),
+ )
+ view: SemanticView = SemanticView(id=17, name="Sales")
+ table_lookup: Mock
+ update_command: Mock
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin"),
+ ),
+ patch("superset.mcp_service.auth.check_tool_permission",
return_value=True),
+ patch.object(
+ update_module.event_logger, "log_context",
return_value=nullcontext()
+ ),
+ patch.object(update_module, "find_chart_by_identifier",
return_value=chart),
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id", return_value=None
+ ) as table_lookup,
+ patch(
+ "superset.daos.datasource.DatasourceDAO.get_datasource",
+ return_value=view,
+ ),
+ patch.object(
+ SemanticView,
+ "raise_for_access",
+ side_effect=SupersetSecurityException(
+ SupersetError(
+ message="private source detail",
+
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ )
+ if outcome == "denied"
+ else None,
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand") as
update_command,
+ ):
+ async with Client(mcp) as client:
+ response: CallToolResult = await client.call_tool(
+ "update_chart",
+ {"request": {"identifier": 9, "chart_name": "Renamed"}},
+ )
+ result: dict[str, Any] = response.structured_content or {}
+ assert result["success"] is False, result
+ error: dict[str, Any] = result["error"]
+ table_lookup.assert_not_called()
+ update_command.assert_not_called()
+ if outcome == "denied":
+ assert error["error_type"] == "DatasetNotAccessible"
+ assert "private source detail" not in error["message"]
+ else:
+ assert error["error_type"] == "UnsupportedDatasourceType"
+ assert "semantic view" in error["message"]