You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ri...@apache.org on 2026/04/24 20:37:23 UTC

(superset) branch master updated: fix(mcp): redact dashboard data model metadata (#39632)

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

richardfogaca 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 57e563b1771 fix(mcp): redact dashboard data model metadata (#39632)
57e563b1771 is described below

commit 57e563b177122e5e58dd7bb9a47b24c5e5558302
Author: Richard Fogaca Nienkotter <63...@users.noreply.github.com>
AuthorDate: Fri Apr 24 17:37:15 2026 -0300

    fix(mcp): redact dashboard data model metadata (#39632)
---
 superset/mcp_service/dashboard/schemas.py          |  86 ++++++++---
 .../tool/add_chart_to_existing_dashboard.py        |  14 +-
 .../dashboard/tool/generate_dashboard.py           |  14 +-
 .../dashboard/tool/get_dashboard_info.py           |   9 +-
 superset/mcp_service/privacy.py                    |   2 +
 .../dashboard/test_dashboard_schemas.py            | 154 ++++++++++++++++++-
 .../dashboard/tool/test_dashboard_generation.py    |  78 ++++++++++
 .../dashboard/tool/test_dashboard_tools.py         | 169 +++++++++++++++++++++
 8 files changed, 494 insertions(+), 32 deletions(-)

diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index 5ca06beca5e..9c50258b9ab 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -86,7 +86,10 @@ if TYPE_CHECKING:
 from superset.daos.base import ColumnOperator, ColumnOperatorEnum
 from superset.mcp_service.common.cache_schemas import MetadataCacheControl
 from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
-from superset.mcp_service.privacy import filter_user_directory_fields
+from superset.mcp_service.privacy import (
+    filter_user_directory_fields,
+    user_can_view_data_model_metadata,
+)
 from superset.mcp_service.system.schemas import (
     PaginationInfo,
     RoleInfo,
@@ -353,8 +356,8 @@ class DashboardInfo(BaseModel):
         default_factory=list,
         description=(
             "Native filters configured on this dashboard. Extracted from "
-            "json_metadata for LLM consumption — includes filter name, type, "
-            "and target columns."
+            "json_metadata for LLM consumption. Includes filter name/type, "
+            "and target columns only when data-model metadata is allowed."
         ),
     )
     cross_filters_enabled: bool | None = Field(
@@ -386,7 +389,8 @@ class DashboardInfo(BaseModel):
         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."
+            "filters the user has applied to the dashboard. For users without "
+            "data-model metadata access, dataMask and chartStates are omitted."
         ),
     )
     is_permalink_state: bool = Field(
@@ -609,12 +613,17 @@ def _parse_json_metadata(json_metadata_str: str | None) -> Dict[str, Any] | None
     return metadata
 
 
-def _extract_native_filters(json_metadata_str: str | None) -> List[NativeFilterSummary]:
+def _extract_native_filters(
+    json_metadata_str: str | None,
+    *,
+    include_data_model_metadata: bool = False,
+) -> List[NativeFilterSummary]:
     """Extract native filter summaries from raw json_metadata string.
 
     Parses the json_metadata JSON blob and pulls out only the filter
-    name, type, and targets — dropping verbose fields like controlValues,
-    defaultDataMask, scope, and cascadeParentIds.
+    name, type, and optionally targets — dropping verbose fields like controlValues,
+    defaultDataMask, scope, and cascadeParentIds. Restricted users keep filter
+    names and types, but target columns and dataset IDs are data-model metadata.
     """
     metadata = _parse_json_metadata(json_metadata_str)
     if metadata is None:
@@ -631,7 +640,11 @@ def _extract_native_filters(json_metadata_str: str | None) -> List[NativeFilterS
         raw_targets = f.get("targets", [])
         if not isinstance(raw_targets, list):
             raw_targets = []
-        targets = [t for t in raw_targets if isinstance(t, dict)]
+        targets = (
+            [t for t in raw_targets if isinstance(t, dict)]
+            if include_data_model_metadata
+            else []
+        )
         summaries.append(
             NativeFilterSummary(
                 id=f.get("id"),
@@ -686,7 +699,11 @@ def _build_omitted_fields(
     )
 
 
-def serialize_chart_summary(chart: Any) -> DashboardChartSummary | None:
+def serialize_chart_summary(
+    chart: Any,
+    *,
+    include_data_model_metadata: bool = False,
+) -> DashboardChartSummary | None:
     """Serialize a chart to a lightweight summary for dashboard context."""
     if not chart:
         return None
@@ -701,18 +718,34 @@ def serialize_chart_summary(chart: Any) -> DashboardChartSummary | None:
         id=chart_id,
         slice_name=getattr(chart, "slice_name", None),
         viz_type=getattr(chart, "viz_type", None),
-        datasource_name=getattr(chart, "datasource_name", None),
+        datasource_name=getattr(chart, "datasource_name", None)
+        if include_data_model_metadata
+        else None,
         url=chart_url,
         description=getattr(chart, "description", None),
     )
 
 
+def redact_filter_state_data_model_metadata(
+    filter_state: Dict[str, Any],
+) -> Dict[str, Any]:
+    """Remove permalink filter state fields that expose data-model metadata."""
+    return {
+        key: value
+        for key, value in filter_state.items()
+        if key not in {"dataMask", "chartStates"}
+    }
+
+
 def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
     from superset.mcp_service.utils.url_utils import get_superset_base_url
 
+    include_data_model_metadata = user_can_view_data_model_metadata()
     base_url = get_superset_base_url()
     relative_url = dashboard.url  # e.g. "/superset/dashboard/{slug_or_id}/"
     absolute_url = f"{base_url}{relative_url}" if relative_url else None
+    json_metadata_str = getattr(dashboard, "json_metadata", None)
+    position_json_str = getattr(dashboard, "position_json", None)
 
     return DashboardInfo(
         id=dashboard.id,
@@ -733,14 +766,13 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
         changed_on_humanized=dashboard.changed_on_humanized,
         chart_count=len(dashboard.slices) if dashboard.slices else 0,
         native_filters=_extract_native_filters(
-            getattr(dashboard, "json_metadata", None)
-        ),
-        cross_filters_enabled=_extract_cross_filters_enabled(
-            getattr(dashboard, "json_metadata", None)
+            json_metadata_str,
+            include_data_model_metadata=include_data_model_metadata,
         ),
+        cross_filters_enabled=_extract_cross_filters_enabled(json_metadata_str),
         omitted_fields=_build_omitted_fields(
-            getattr(dashboard, "json_metadata", None),
-            getattr(dashboard, "position_json", None),
+            json_metadata_str,
+            position_json_str,
         ),
         tags=[
             TagInfo.model_validate(tag, from_attributes=True) for tag in dashboard.tags
@@ -750,7 +782,13 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
         charts=[
             summary
             for chart in dashboard.slices
-            if (summary := serialize_chart_summary(chart)) is not None
+            if (
+                summary := serialize_chart_summary(
+                    chart,
+                    include_data_model_metadata=include_data_model_metadata,
+                )
+            )
+            is not None
         ]
         if dashboard.slices
         else [],
@@ -780,6 +818,7 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
 
     json_metadata_str = getattr(dashboard, "json_metadata", None)
     position_json_str = getattr(dashboard, "position_json", None)
+    include_data_model_metadata = user_can_view_data_model_metadata()
 
     return DashboardInfo(
         id=dashboard_id,
@@ -799,7 +838,10 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
         css=getattr(dashboard, "css", None),
         certified_by=getattr(dashboard, "certified_by", None),
         certification_details=getattr(dashboard, "certification_details", None),
-        native_filters=_extract_native_filters(json_metadata_str),
+        native_filters=_extract_native_filters(
+            json_metadata_str,
+            include_data_model_metadata=include_data_model_metadata,
+        ),
         cross_filters_enabled=_extract_cross_filters_enabled(json_metadata_str),
         omitted_fields=_build_omitted_fields(json_metadata_str, position_json_str),
         is_managed_externally=getattr(dashboard, "is_managed_externally", None),
@@ -817,7 +859,13 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
         charts=[
             summary
             for chart in getattr(dashboard, "slices", [])
-            if (summary := serialize_chart_summary(chart)) is not None
+            if (
+                summary := serialize_chart_summary(
+                    chart,
+                    include_data_model_metadata=include_data_model_metadata,
+                )
+            )
+            is not None
         ]
         if getattr(dashboard, "slices", None)
         else [],
diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
index a323c42d918..026275c9e4b 100644
--- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
@@ -42,6 +42,7 @@ from superset.mcp_service.dashboard.schemas import (
     DashboardInfo,
     serialize_chart_summary,
 )
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
 
@@ -531,6 +532,7 @@ def add_chart_to_existing_dashboard(
             serialize_tag_object,
         )
 
+        include_data_model_metadata = user_can_view_data_model_metadata()
         dashboard_info = DashboardInfo(
             id=updated_dashboard.id,
             dashboard_title=updated_dashboard.dashboard_title,
@@ -539,22 +541,24 @@ def add_chart_to_existing_dashboard(
             published=updated_dashboard.published,
             created_on=updated_dashboard.created_on,
             changed_on=updated_dashboard.changed_on,
-            created_by=None,
-            changed_by=None,
             uuid=str(updated_dashboard.uuid) if updated_dashboard.uuid else None,
             url=f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/",
             chart_count=len(updated_dashboard.slices),
-            owners=[],
             tags=[
                 serialize_tag_object(tag)
                 for tag in getattr(updated_dashboard, "tags", [])
                 if serialize_tag_object(tag) is not None
             ],
-            roles=[],
             charts=[
                 obj
                 for chart in getattr(updated_dashboard, "slices", [])
-                if (obj := serialize_chart_summary(chart)) is not None
+                if (
+                    obj := serialize_chart_summary(
+                        chart,
+                        include_data_model_metadata=include_data_model_metadata,
+                    )
+                )
+                is not None
             ],
         )
 
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index 8c0697aded8..968d5ca74f2 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -39,6 +39,7 @@ from superset.mcp_service.dashboard.schemas import (
     GenerateDashboardRequest,
     GenerateDashboardResponse,
 )
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
 
@@ -405,6 +406,7 @@ def generate_dashboard(  # noqa: C901
             serialize_tag_object,
         )
 
+        include_data_model_metadata = user_can_view_data_model_metadata()
         dashboard_info = DashboardInfo(
             id=dashboard.id,
             dashboard_title=dashboard.dashboard_title,
@@ -413,22 +415,24 @@ def generate_dashboard(  # noqa: C901
             published=dashboard.published,
             created_on=dashboard.created_on,
             changed_on=dashboard.changed_on,
-            created_by=None,
-            changed_by=None,
             uuid=str(dashboard.uuid) if dashboard.uuid else None,
             url=f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/",
             chart_count=len(request.chart_ids),
-            owners=[],
             tags=[
                 serialize_tag_object(tag)
                 for tag in getattr(dashboard, "tags", [])
                 if serialize_tag_object(tag) is not None
             ],
-            roles=[],  # Dashboard roles not typically set at creation
             charts=[
                 obj
                 for chart in getattr(dashboard, "slices", [])
-                if (obj := serialize_chart_summary(chart)) is not None
+                if (
+                    obj := serialize_chart_summary(
+                        chart,
+                        include_data_model_metadata=include_data_model_metadata,
+                    )
+                )
+                is not None
             ],
         )
 
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
index 71e60279003..9c52bbc7f2d 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
@@ -37,8 +37,10 @@ from superset.mcp_service.dashboard.schemas import (
     DashboardError,
     DashboardInfo,
     GetDashboardInfoRequest,
+    redact_filter_state_data_model_metadata,
 )
 from superset.mcp_service.mcp_core import ModelGetInfoCore
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
 
 logger = logging.getLogger(__name__)
 
@@ -165,15 +167,20 @@ async def get_dashboard_info(
                         permalink_state = (
                             dict(raw_state) if isinstance(raw_state, dict) else {}
                         )
+                        if not user_can_view_data_model_metadata():
+                            permalink_state = redact_filter_state_data_model_metadata(
+                                permalink_state
+                            )
                         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"
+                            "has_dataMask=%s, has_chartStates=%s, has_activeTabs=%s"
                             % (
                                 "dataMask" in permalink_state,
+                                "chartStates" in permalink_state,
                                 "activeTabs" in permalink_state,
                             )
                         )
diff --git a/superset/mcp_service/privacy.py b/superset/mcp_service/privacy.py
index 74949c84f2c..25b34a5b6c8 100644
--- a/superset/mcp_service/privacy.py
+++ b/superset/mcp_service/privacy.py
@@ -106,6 +106,8 @@ def user_can_view_data_model_metadata() -> bool:
     Dataset drill/write permissions indicate active data-model introspection access.
     Dashboard-only viewers may have Dataset read access for chart rendering, but that
     should not expose dataset/database metadata through MCP tools.
+    These resource-type permissions intentionally gate metadata globally rather
+    than per dashboard chart.
     """
     try:
         from superset import security_manager
diff --git a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
index 07f99004d45..9850b2bb2cb 100644
--- a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
+++ b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
@@ -30,7 +30,9 @@ from pydantic import ValidationError
 from superset.mcp_service.dashboard.schemas import (
     _extract_cross_filters_enabled,
     _extract_native_filters,
+    dashboard_serializer,
     GenerateDashboardRequest,
+    serialize_chart_summary,
     serialize_dashboard_object,
 )
 from superset.utils.json import dumps as json_dumps
@@ -138,9 +140,15 @@ class TestSerializeDashboardObject:
         assert not hasattr(result, "json_metadata")
         assert not hasattr(result, "position_json")
 
+    @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
     @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
-    def test_native_filters_extracted_from_json_metadata(self, mock_base_url):
+    def test_native_filters_extracted_from_json_metadata(
+        self,
+        mock_base_url,
+        mock_can_view_data_model_metadata,
+    ):
         """Native filters should be extracted from json_metadata."""
+        mock_can_view_data_model_metadata.return_value = True
         mock_base_url.return_value = "http://localhost:8088"
 
         metadata = {
@@ -178,9 +186,49 @@ class TestSerializeDashboardObject:
         assert result.native_filters[1].name == "Date Range"
         assert result.cross_filters_enabled is True
 
+    @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
     @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
-    def test_chart_summaries_are_lightweight(self, mock_base_url):
+    def test_restricted_user_redacts_native_filter_targets(
+        self,
+        mock_base_url,
+        mock_can_view_data_model_metadata,
+    ):
+        mock_can_view_data_model_metadata.return_value = False
+        mock_base_url.return_value = "http://localhost:8088"
+
+        metadata = {
+            "native_filter_configuration": [
+                {
+                    "id": "NATIVE_FILTER-abc123",
+                    "name": "Product Line",
+                    "filterType": "filter_select",
+                    "targets": [
+                        {"column": {"name": "product_line"}, "datasetId": 3},
+                    ],
+                },
+            ],
+            "cross_filters_enabled": True,
+        }
+        dashboard = _mock_dashboard(id=1)
+        dashboard.json_metadata = json_dumps(metadata)
+
+        result = serialize_dashboard_object(dashboard)
+
+        assert len(result.native_filters) == 1
+        assert result.native_filters[0].name == "Product Line"
+        assert result.native_filters[0].filter_type == "filter_select"
+        assert result.native_filters[0].targets == []
+        assert result.cross_filters_enabled is True
+
+    @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
+    @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
+    def test_chart_summaries_are_lightweight(
+        self,
+        mock_base_url,
+        mock_can_view_data_model_metadata,
+    ):
         """Charts in dashboard response should only have core fields."""
+        mock_can_view_data_model_metadata.return_value = True
         mock_base_url.return_value = "http://localhost:8088"
 
         chart = MagicMock()
@@ -204,6 +252,71 @@ class TestSerializeDashboardObject:
         assert not hasattr(result.charts[0], "tags")
         assert not hasattr(result.charts[0], "owners")
 
+    @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
+    @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
+    def test_restricted_user_redacts_chart_datasource_name(
+        self,
+        mock_base_url,
+        mock_can_view_data_model_metadata,
+    ):
+        mock_can_view_data_model_metadata.return_value = False
+        mock_base_url.return_value = "http://localhost:8088"
+
+        chart = MagicMock()
+        chart.id = 5
+        chart.slice_name = "Revenue Chart"
+        chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_name = "sales"
+        chart.description = "Monthly revenue"
+
+        dashboard = _mock_dashboard(id=1, slices=[chart])
+        result = serialize_dashboard_object(dashboard)
+
+        assert len(result.charts) == 1
+        assert result.charts[0].slice_name == "Revenue Chart"
+        assert result.charts[0].viz_type == "echarts_timeseries_bar"
+        assert result.charts[0].datasource_name is None
+        assert result.charts[0].url == "http://localhost:8088/explore/?slice_id=5"
+
+    @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
+    @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
+    def test_dashboard_serializer_restricted_user_redacts_data_model_metadata(
+        self,
+        mock_base_url,
+        mock_can_view_data_model_metadata,
+    ):
+        mock_can_view_data_model_metadata.return_value = False
+        mock_base_url.return_value = "http://localhost:8088"
+
+        chart = MagicMock()
+        chart.id = 5
+        chart.slice_name = "Revenue Chart"
+        chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_name = "sales"
+        chart.description = "Monthly revenue"
+
+        metadata = {
+            "native_filter_configuration": [
+                {
+                    "id": "NATIVE_FILTER-abc123",
+                    "name": "Product Line",
+                    "filterType": "filter_select",
+                    "targets": [
+                        {"column": {"name": "product_line"}, "datasetId": 3},
+                    ],
+                },
+            ],
+            "cross_filters_enabled": True,
+        }
+        dashboard = _mock_dashboard(id=1, slices=[chart])
+        dashboard.url = "/superset/dashboard/1/"
+        dashboard.json_metadata = json_dumps(metadata)
+
+        result = dashboard_serializer(dashboard)
+
+        assert result.charts[0].datasource_name is None
+        assert result.native_filters[0].targets == []
+
 
 class TestExtractNativeFilters:
     """Tests for _extract_native_filters helper."""
@@ -241,6 +354,26 @@ class TestExtractNativeFilters:
         assert result[0].id == "f1"
         assert result[0].name == "Filter 1"
         assert result[0].filter_type == "filter_select"
+        assert result[0].targets == []
+
+    def test_valid_filters_include_targets_when_metadata_allowed(self):
+        metadata = json_dumps(
+            {
+                "native_filter_configuration": [
+                    {
+                        "id": "f1",
+                        "name": "Filter 1",
+                        "filterType": "filter_select",
+                        "targets": [{"column": {"name": "col1"}}],
+                    }
+                ]
+            }
+        )
+        result = _extract_native_filters(
+            metadata,
+            include_data_model_metadata=True,
+        )
+        assert result[0].targets == [{"column": {"name": "col1"}}]
 
     def test_skips_non_dict_entries(self):
         metadata = json_dumps(
@@ -285,6 +418,23 @@ class TestExtractCrossFiltersEnabled:
         assert _extract_cross_filters_enabled('"just a string"') is None
 
 
+class TestSerializeChartSummary:
+    """Tests for serialize_chart_summary helper."""
+
+    def test_datasource_name_redacted_by_default(self):
+        chart = MagicMock()
+        chart.id = 5
+        chart.slice_name = "Revenue Chart"
+        chart.viz_type = "echarts_timeseries_bar"
+        chart.datasource_name = "sales"
+        chart.description = "Monthly revenue"
+
+        result = serialize_chart_summary(chart)
+
+        assert result is not None
+        assert result.datasource_name is None
+
+
 class TestOmittedFieldsBuilder:
     """Tests for the shared OmittedFieldsBuilder utility."""
 
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
index b5df3e91abc..e5e99f57b5a 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
@@ -20,6 +20,7 @@ Unit tests for dashboard generation MCP tools
 """
 
 import logging
+from importlib import import_module
 from unittest.mock import Mock, patch
 
 import pytest
@@ -41,6 +42,12 @@ from superset.utils import json
 
 logging.basicConfig(level=logging.DEBUG)
 logger = logging.getLogger(__name__)
+add_chart_to_existing_dashboard_module = import_module(
+    "superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard"
+)
+generate_dashboard_module = import_module(
+    "superset.mcp_service.dashboard.tool.generate_dashboard"
+)
 
 
 @pytest.fixture
@@ -201,6 +208,39 @@ class TestGenerateDashboard:
                 "/superset/dashboard/10/" in result.structured_content["dashboard_url"]
             )
 
+    @patch("superset.models.dashboard.Dashboard")
+    @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+    @patch("superset.db.session")
+    @pytest.mark.asyncio
+    async def test_generate_dashboard_restricted_user_redacts_chart_datasource_name(
+        self, mock_db_session, mock_find_by_id, mock_dashboard_cls, mcp_server
+    ):
+        chart = _mock_chart(id=1, slice_name="Sales Chart")
+        chart.datasource_name = "Vehicle Sales"
+        dashboard = _mock_dashboard(id=10, title="Analytics Dashboard")
+        dashboard.slices = [chart]
+        _setup_generate_dashboard_mocks(
+            mock_db_session, mock_find_by_id, mock_dashboard_cls, [chart], dashboard
+        )
+
+        request = {"chart_ids": [1], "dashboard_title": "Analytics Dashboard"}
+
+        with patch.object(
+            generate_dashboard_module,
+            "user_can_view_data_model_metadata",
+            return_value=False,
+        ):
+            async with Client(mcp_server) as client:
+                result = await client.call_tool(
+                    "generate_dashboard", {"request": request}
+                )
+
+        assert result.structured_content["error"] is None
+        assert (
+            result.structured_content["dashboard"]["charts"][0]["datasource_name"]
+            is None
+        )
+
     @patch("superset.db.session")
     @pytest.mark.asyncio
     async def test_generate_dashboard_missing_charts(self, mock_db_session, mcp_server):
@@ -625,6 +665,44 @@ class TestAddChartToExistingDashboard:
             assert column_key in layout
             assert layout[column_key]["type"] == "COLUMN"
 
+    @patch("superset.commands.dashboard.update.UpdateDashboardCommand")
+    @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+    @patch("superset.db.session")
+    @pytest.mark.asyncio
+    async def test_add_chart_restricted_user_redacts_chart_datasource_name(
+        self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
+    ):
+        mock_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
+        mock_dashboard.slices = []
+        mock_dashboard.position_json = "{}"
+
+        chart = _mock_chart(id=30, slice_name="New Chart")
+        chart.datasource_name = "Vehicle Sales"
+        mock_db_session.get.return_value = chart
+
+        updated_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
+        updated_dashboard.slices = [chart]
+        mock_update_command.return_value.run.return_value = updated_dashboard
+        mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard]
+
+        request = {"dashboard_id": 1, "chart_id": 30}
+
+        with patch.object(
+            add_chart_to_existing_dashboard_module,
+            "user_can_view_data_model_metadata",
+            return_value=False,
+        ):
+            async with Client(mcp_server) as client:
+                result = await client.call_tool(
+                    "add_chart_to_existing_dashboard", {"request": request}
+                )
+
+        assert result.structured_content["error"] is None
+        assert (
+            result.structured_content["dashboard"]["charts"][0]["datasource_name"]
+            is None
+        )
+
     @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
     @pytest.mark.asyncio
     async def test_add_chart_dashboard_not_found(self, mock_find_dashboard, mcp_server):
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py
index d47c7651bd2..05d224fc513 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py
@@ -20,6 +20,7 @@ Unit tests for MCP dashboard tools (list_dashboards, get_dashboard_info)
 """
 
 import logging
+from importlib import import_module
 from unittest.mock import Mock, patch
 
 import pytest
@@ -34,6 +35,9 @@ from superset.utils import json
 
 logging.basicConfig(level=logging.DEBUG)
 logger = logging.getLogger(__name__)
+get_dashboard_info_module = import_module(
+    "superset.mcp_service.dashboard.tool.get_dashboard_info"
+)
 
 
 @pytest.fixture
@@ -448,6 +452,171 @@ async def test_get_dashboard_info_does_not_expose_access_list_or_roles(
     assert "owners" not in result.data["charts"][0]
 
 
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_info_restricted_user_redacts_data_model_metadata(
+    mock_info,
+    mcp_server,
+):
+    chart = Mock()
+    chart.id = 10
+    chart.slice_name = "Revenue by Deal Size"
+    chart.viz_type = "echarts_timeseries_bar"
+    chart.datasource_name = "Vehicle Sales"
+    chart.description = None
+    chart.tags = []
+
+    dashboard = Mock()
+    dashboard.id = 1
+    dashboard.dashboard_title = "Sales Dashboard"
+    dashboard.slug = "sales"
+    dashboard.description = None
+    dashboard.css = None
+    dashboard.certified_by = None
+    dashboard.certification_details = None
+    dashboard.json_metadata = json.dumps(
+        {
+            "native_filter_configuration": [
+                {
+                    "id": "NATIVE_FILTER-product-line",
+                    "name": "Product Line",
+                    "filterType": "filter_select",
+                    "targets": [
+                        {"column": {"name": "product_line"}, "datasetId": 3},
+                    ],
+                },
+            ],
+            "cross_filters_enabled": True,
+        }
+    )
+    dashboard.position_json = None
+    dashboard.published = True
+    dashboard.is_managed_externally = False
+    dashboard.external_url = None
+    dashboard.created_on = None
+    dashboard.changed_on = None
+    dashboard.created_by = None
+    dashboard.changed_by = None
+    dashboard.uuid = None
+    dashboard.url = "/dashboard/1"
+    dashboard.created_on_humanized = None
+    dashboard.changed_on_humanized = None
+    dashboard.slices = [chart]
+    dashboard.owners = []
+    dashboard.tags = []
+    dashboard.roles = []
+
+    mock_info.return_value = dashboard
+
+    with patch(
+        "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
+        return_value=False,
+    ):
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "get_dashboard_info",
+                {"request": {"identifier": 1}},
+            )
+
+    assert result.data["dashboard_title"] == "Sales Dashboard"
+    assert result.data["charts"][0]["slice_name"] == "Revenue by Deal Size"
+    assert result.data["charts"][0]["viz_type"] == "echarts_timeseries_bar"
+    assert result.data["charts"][0]["datasource_name"] is None
+    assert result.data["native_filters"][0]["name"] == "Product Line"
+    assert result.data["native_filters"][0]["targets"] == []
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_info_restricted_user_redacts_permalink_filter_state(
+    mock_info,
+    mcp_server,
+):
+    dashboard = Mock()
+    dashboard.id = 1
+    dashboard.dashboard_title = "Sales Dashboard"
+    dashboard.slug = "sales"
+    dashboard.description = None
+    dashboard.css = None
+    dashboard.certified_by = None
+    dashboard.certification_details = None
+    dashboard.json_metadata = None
+    dashboard.position_json = None
+    dashboard.published = True
+    dashboard.is_managed_externally = False
+    dashboard.external_url = None
+    dashboard.created_on = None
+    dashboard.changed_on = None
+    dashboard.created_by = None
+    dashboard.changed_by = None
+    dashboard.uuid = None
+    dashboard.url = "/dashboard/1"
+    dashboard.created_on_humanized = None
+    dashboard.changed_on_humanized = None
+    dashboard.slices = []
+    dashboard.owners = []
+    dashboard.tags = []
+    dashboard.roles = []
+
+    mock_info.return_value = dashboard
+
+    permalink_value = {
+        "dashboardId": "1",
+        "state": {
+            "dataMask": {
+                "NATIVE_FILTER-product-line": {
+                    "extraFormData": {
+                        "filters": [
+                            {
+                                "col": "product_line",
+                                "op": "IN",
+                                "val": ["Classic Cars"],
+                                "datasetId": 3,
+                            }
+                        ],
+                    },
+                    "filterState": {"value": ["Classic Cars"]},
+                },
+            },
+            "chartStates": {
+                "42": {
+                    "state": {
+                        "columnState": [{"colId": "customer_email"}],
+                        "filterModel": {"revenue": {"filter": 100}},
+                    },
+                },
+            },
+            "activeTabs": ["TAB-products"],
+        },
+    }
+
+    with (
+        patch(
+            "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
+            return_value=False,
+        ),
+        patch.object(
+            get_dashboard_info_module,
+            "user_can_view_data_model_metadata",
+            return_value=False,
+        ),
+        patch.object(
+            get_dashboard_info_module,
+            "_get_permalink_state",
+            return_value=permalink_value,
+        ),
+    ):
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "get_dashboard_info",
+                {"request": {"identifier": 1, "permalink_key": "abc123"}},
+            )
+
+    assert result.data["permalink_key"] == "abc123"
+    assert result.data["is_permalink_state"] is True
+    assert result.data["filter_state"] == {"activeTabs": ["TAB-products"]}
+
+
 @patch("superset.daos.dashboard.DashboardDAO.list")
 @pytest.mark.asyncio
 async def test_list_dashboards_omits_requested_user_directory_fields(