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 3fa06db85b6 fix(mcp): include feature_availability in
instance://metadata resource (#44891)
3fa06db85b6 is described below
commit 3fa06db85b6320364d3a4747aca0447ede179020
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Sat Oct 3 09:50:28 2026 +1000
fix(mcp): include feature_availability in instance://metadata resource
(#44891)
---
.../system/resources/instance_metadata.py | 37 +++---
superset/mcp_service/system/system_utils.py | 33 ++++-
.../mcp_service/system/tool/get_instance_info.py | 38 +-----
.../system/resources/test_instance_metadata.py | 144 +++++++++++++++++++++
.../system/tool/test_get_current_user.py | 6 +-
5 files changed, 202 insertions(+), 56 deletions(-)
diff --git a/superset/mcp_service/system/resources/instance_metadata.py
b/superset/mcp_service/system/resources/instance_metadata.py
index 29e223f06e1..7fe593aa210 100644
--- a/superset/mcp_service/system/resources/instance_metadata.py
+++ b/superset/mcp_service/system/resources/instance_metadata.py
@@ -56,13 +56,12 @@ def get_instance_metadata_resource() -> str:
from superset.daos.tag import TagDAO
from superset.daos.user import UserDAO
from superset.mcp_service.mcp_core import InstanceInfoCore
+ from superset.mcp_service.privacy import
user_can_view_data_model_metadata
from superset.mcp_service.system.schemas import InstanceInfo
from superset.mcp_service.system.system_utils import (
- calculate_dashboard_breakdown,
- calculate_database_breakdown,
- calculate_instance_summary,
- calculate_popular_content,
- calculate_recent_activity,
+ INSTANCE_INFO_METRIC_CALCULATORS,
+ INSTANCE_INFO_TIME_WINDOWS,
+ redact_data_model_metadata,
)
from superset.utils import json
@@ -76,29 +75,29 @@ def get_instance_metadata_resource() -> str:
"tags": cast(Type[BaseDAO[Any]], TagDAO),
},
output_schema=InstanceInfo,
- metric_calculators={
- "instance_summary": calculate_instance_summary,
- "recent_activity": calculate_recent_activity,
- "dashboard_breakdown": calculate_dashboard_breakdown,
- "database_breakdown": calculate_database_breakdown,
- "popular_content": calculate_popular_content,
- },
- time_windows={
- "recent": 7,
- "monthly": 30,
- "quarterly": 90,
- },
+ metric_calculators=INSTANCE_INFO_METRIC_CALCULATORS,
+ time_windows=INSTANCE_INFO_TIME_WINDOWS,
logger=logger,
)
- # Get base instance info
- base_result = json.loads(instance_info_core.get_resource())
+ # Get base instance info, redacting data model metadata for principals
+ # that the get_instance_info tool also hides it from.
+ can_view_data_model = user_can_view_data_model_metadata()
+ instance_info = instance_info_core.run_tool()
+ if not can_view_data_model:
+ instance_info = redact_data_model_metadata(instance_info)
+ base_result = json.loads(json.dumps(instance_info.model_dump()))
# Remove empty popular_content if it has no useful data
popular = base_result.get("popular_content", {})
if popular and not any(popular.get(k) for k in popular):
del base_result["popular_content"]
+ if not can_view_data_model:
+ base_result["available_datasets"] = []
+ base_result["available_databases"] = []
+ return json.dumps(base_result, indent=2)
+
# Add available datasets (top 20 by most recent modification)
dataset_dao = instance_info_core.dao_classes["datasets"]
try:
diff --git a/superset/mcp_service/system/system_utils.py
b/superset/mcp_service/system/system_utils.py
index 75fe81af5f8..292dd809c92 100644
--- a/superset/mcp_service/system/system_utils.py
+++ b/superset/mcp_service/system/system_utils.py
@@ -23,12 +23,13 @@ instance metrics, dashboard breakdowns, database
breakdowns, and activity summar
"""
import logging
-from typing import Any, Dict
+from typing import Any, Callable, Dict
from superset.mcp_service.system.schemas import (
DashboardBreakdown,
DatabaseBreakdown,
FeatureAvailability,
+ InstanceInfo,
InstanceSummary,
PopularContent,
RecentActivity,
@@ -223,3 +224,33 @@ def calculate_feature_availability(
return FeatureAvailability(
accessible_menus=accessible_menus,
)
+
+
+def redact_data_model_metadata(result: InstanceInfo) -> InstanceInfo:
+ """Remove dataset/database counts and activity from instance overview."""
+ data = result.model_copy(deep=True)
+ data.instance_summary.total_datasets = 0
+ data.instance_summary.total_databases = 0
+ data.recent_activity.datasets_created_last_30_days = 0
+ data.recent_activity.datasets_modified_last_7_days = 0
+ data.database_breakdown.by_type = {}
+ data.data_model_metadata_redacted = True
+ return data
+
+
+# Shared by the get_instance_info tool and the instance://metadata resource so
a
+# newly required InstanceInfo field only needs a calculator registered once.
+INSTANCE_INFO_METRIC_CALCULATORS: Dict[str, Callable[..., Any]] = {
+ "instance_summary": calculate_instance_summary,
+ "recent_activity": calculate_recent_activity,
+ "dashboard_breakdown": calculate_dashboard_breakdown,
+ "database_breakdown": calculate_database_breakdown,
+ "popular_content": calculate_popular_content,
+ "feature_availability": calculate_feature_availability,
+}
+
+INSTANCE_INFO_TIME_WINDOWS: Dict[str, int] = {
+ "recent": 7,
+ "monthly": 30,
+ "quarterly": 90,
+}
diff --git a/superset/mcp_service/system/tool/get_instance_info.py
b/superset/mcp_service/system/tool/get_instance_info.py
index bf6ff243036..ff9a499a0eb 100644
--- a/superset/mcp_service/system/tool/get_instance_info.py
+++ b/superset/mcp_service/system/tool/get_instance_info.py
@@ -35,12 +35,9 @@ from superset.mcp_service.system.schemas import (
serialize_user_object,
)
from superset.mcp_service.system.system_utils import (
- calculate_dashboard_breakdown,
- calculate_database_breakdown,
- calculate_feature_availability,
- calculate_instance_summary,
- calculate_popular_content,
- calculate_recent_activity,
+ INSTANCE_INFO_METRIC_CALCULATORS,
+ INSTANCE_INFO_TIME_WINDOWS,
+ redact_data_model_metadata,
)
logger = logging.getLogger(__name__)
@@ -57,19 +54,8 @@ _instance_info_core = InstanceInfoCore(
"tags": None, # type: ignore[dict-item]
},
output_schema=InstanceInfo,
- metric_calculators={
- "instance_summary": calculate_instance_summary,
- "recent_activity": calculate_recent_activity,
- "dashboard_breakdown": calculate_dashboard_breakdown,
- "database_breakdown": calculate_database_breakdown,
- "popular_content": calculate_popular_content,
- "feature_availability": calculate_feature_availability,
- },
- time_windows={
- "recent": 7,
- "monthly": 30,
- "quarterly": 90,
- },
+ metric_calculators=INSTANCE_INFO_METRIC_CALCULATORS,
+ time_windows=INSTANCE_INFO_TIME_WINDOWS,
logger=logger,
)
@@ -77,18 +63,6 @@ _instance_info_core = InstanceInfoCore(
_DEFAULT_INSTANCE_INFO_REQUEST = GetSupersetInstanceInfoRequest()
-def _redact_data_model_metadata(result: InstanceInfo) -> InstanceInfo:
- """Remove dataset/database counts and activity from instance overview."""
- data = result.model_copy(deep=True)
- data.instance_summary.total_datasets = 0
- data.instance_summary.total_databases = 0
- data.recent_activity.datasets_created_last_30_days = 0
- data.recent_activity.datasets_modified_last_7_days = 0
- data.database_breakdown.by_type = {}
- data.data_model_metadata_redacted = True
- return data
-
-
@tool(
tags=["core"],
annotations=ToolAnnotations(
@@ -176,7 +150,7 @@ def _run_instance_info() -> InstanceInfo:
result = _instance_info_core.run_tool()
if not user_can_view_data_model_metadata():
- result = _redact_data_model_metadata(result)
+ result = redact_data_model_metadata(result)
if (user := getattr(g, "user", None)) is not None:
result.current_user = serialize_user_object(user)
diff --git
a/tests/unit_tests/mcp_service/system/resources/test_instance_metadata.py
b/tests/unit_tests/mcp_service/system/resources/test_instance_metadata.py
new file mode 100644
index 00000000000..0e7972affd7
--- /dev/null
+++ b/tests/unit_tests/mcp_service/system/resources/test_instance_metadata.py
@@ -0,0 +1,144 @@
+# 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.
+
+"""Tests for the instance://metadata MCP resource."""
+
+from collections.abc import Iterator
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.system.schemas import FeatureAvailability,
InstanceInfo
+from superset.mcp_service.system.tool.get_instance_info import
_instance_info_core
+from superset.utils import json
+
+INSTANCE_METADATA_URI = "instance://metadata"
+
+
[email protected](autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Mock authentication for all tests."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as
mock_get_user:
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
[email protected](autouse=True)
+def can_view() -> bool:
+ """Whether the mocked user may inspect data model metadata."""
+ return True
+
+
[email protected](autouse=True)
+def mock_data_access(can_view: bool) -> Iterator[tuple[Mock, Mock]]:
+ """Avoid hitting the metadata database while generating the resource."""
+ dataset = SimpleNamespace(
+ id=7,
+ table_name="orders",
+ schema="public",
+ database_id=3,
+ changed_on=None,
+ )
+ database = SimpleNamespace(id=3, database_name="examples",
backend="sqlite")
+ with (
+ patch(
+
"superset.mcp_service.mcp_core.InstanceInfoCore._calculate_basic_counts",
+ return_value={"total_datasets": 7, "total_databases": 3},
+ ),
+ patch(
+ "superset.mcp_service.mcp_core.InstanceInfoCore"
+ "._calculate_time_based_metrics",
+ return_value={},
+ ),
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_all", return_value=[dataset]
+ ) as find_datasets,
+ patch(
+ "superset.daos.database.DatabaseDAO.find_all",
return_value=[database]
+ ) as find_databases,
+ patch(
+ "superset.mcp_service.privacy.user_can_view_data_model_metadata",
+ return_value=can_view,
+ ),
+ ):
+ yield find_datasets, find_databases
+
+
+async def _read_metadata() -> dict[str, Any]:
+ async with Client(mcp) as client:
+ result = await client.read_resource(INSTANCE_METADATA_URI)
+ return json.loads(result[0].text)
+
+
[email protected]
+async def test_instance_metadata_resource_returns_valid_payload() -> None:
+ data = await _read_metadata()
+
+ assert "error" not in data
+ # The resource intentionally drops an empty popular_content block, so
restore
+ # it before validating the payload against the full InstanceInfo schema.
+ InstanceInfo.model_validate(
+ {"popular_content": {"top_tags": [], "top_creators": []}, **data}
+ )
+ assert FeatureAvailability.model_validate(data["feature_availability"])
+ assert data["instance_summary"]["total_datasets"] == 7
+ assert data["instance_summary"]["total_databases"] == 3
+ assert data["data_model_metadata_redacted"] is False
+ assert data["available_datasets"] == [
+ {"id": 7, "table_name": "orders", "schema": "public", "database_id": 3}
+ ]
+ assert data["available_databases"] == [
+ {"id": 3, "database_name": "examples", "backend": "sqlite"}
+ ]
+
+
[email protected]
[email protected]("can_view", [False])
+async def test_instance_metadata_resource_redacts_data_model_metadata(
+ mock_data_access: tuple[Mock, Mock],
+) -> None:
+ """Principals without data model access must not see dataset/database
info."""
+ find_datasets, find_databases = mock_data_access
+
+ data = await _read_metadata()
+
+ assert "error" not in data
+ assert data["data_model_metadata_redacted"] is True
+ assert data["instance_summary"]["total_datasets"] == 0
+ assert data["instance_summary"]["total_databases"] == 0
+ assert data["database_breakdown"]["by_type"] == {}
+ assert data["available_datasets"] == []
+ assert data["available_databases"] == []
+ find_datasets.assert_not_called()
+ find_databases.assert_not_called()
+ assert "accessible_menus" in data["feature_availability"]
+
+
+def test_resource_and_tool_share_metric_calculators() -> None:
+ """The tool and resource must compute the same set of InstanceInfo
metrics."""
+ from superset.mcp_service.system.system_utils import (
+ INSTANCE_INFO_METRIC_CALCULATORS,
+ )
+
+ assert _instance_info_core.metric_calculators is
INSTANCE_INFO_METRIC_CALCULATORS
diff --git a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py
b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py
index fd9acad2abc..589ff97c60c 100644
--- a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py
+++ b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py
@@ -138,9 +138,7 @@ def
test_redact_data_model_metadata_removes_dataset_and_database_summary():
InstanceSummary,
RecentActivity,
)
- from superset.mcp_service.system.tool.get_instance_info import (
- _redact_data_model_metadata,
- )
+ from superset.mcp_service.system.system_utils import
redact_data_model_metadata
instance_info = _make_instance_info(
instance_summary=InstanceSummary(
@@ -164,7 +162,7 @@ def
test_redact_data_model_metadata_removes_dataset_and_database_summary():
database_breakdown=DatabaseBreakdown(by_type={"postgresql": 2}),
)
- redacted = _redact_data_model_metadata(instance_info)
+ redacted = redact_data_model_metadata(instance_info)
assert redacted.instance_summary.total_dashboards == 2
assert redacted.instance_summary.total_charts == 4