diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index f43df61e487..5db973067e1 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -152,6 +152,10 @@ Input format: - When MCP_PARSE_REQUEST_ENABLED is True (default), string-serialized JSON is also accepted as input, which works around double-serialization bugs in some MCP clients +Feature Availability: +- Call get_instance_info to discover accessible menus for the current user. +- Do NOT assume features exist; always check get_instance_info first. + If you are unsure which tool to use, start with get_instance_info or use the quickstart prompt for an interactive guide. diff --git a/superset/mcp_service/system/schemas.py b/superset/mcp_service/system/schemas.py index 93b676a0e1a..f59243f4e65 100644 --- a/superset/mcp_service/system/schemas.py +++ b/superset/mcp_service/system/schemas.py @@ -108,6 +108,22 @@ class PopularContent(BaseModel): top_creators: List[str] = Field(..., description="Most active creators") +class FeatureAvailability(BaseModel): + """Dynamic feature availability for the current user and deployment. + + Menus are detected at request time from the security manager, + so they reflect the actual permissions of the requesting user. + """ + + accessible_menus: List[str] = Field( + default_factory=list, + description=( + "UI menu items accessible to the current user, " + "derived from FAB role permissions" + ), + ) + + class InstanceInfo(BaseModel): instance_summary: InstanceSummary = Field( ..., description="Instance summary information" @@ -129,6 +145,12 @@ class InstanceInfo(BaseModel): description="The authenticated user making the request. " "Use current_user.id with created_by_fk filter to find your own assets.", ) + feature_availability: FeatureAvailability = Field( + ..., + description=( + "Dynamic feature availability for the current user and deployment" + ), + ) timestamp: datetime = Field(..., description="Response timestamp") diff --git a/superset/mcp_service/system/system_utils.py b/superset/mcp_service/system/system_utils.py index 43426df4716..b9a4b8759f3 100644 --- a/superset/mcp_service/system/system_utils.py +++ b/superset/mcp_service/system/system_utils.py @@ -22,16 +22,20 @@ This module contains helper functions used by system tools for calculating instance metrics, dashboard breakdowns, database breakdowns, and activity summaries. """ +import logging from typing import Any, Dict from superset.mcp_service.system.schemas import ( DashboardBreakdown, DatabaseBreakdown, + FeatureAvailability, InstanceSummary, PopularContent, RecentActivity, ) +logger = logging.getLogger(__name__) + def calculate_dashboard_breakdown( base_counts: Dict[str, int], @@ -194,3 +198,28 @@ def calculate_popular_content( top_tags=[], top_creators=[], ) + + +def calculate_feature_availability( + base_counts: Dict[str, int], + time_metrics: Dict[str, Dict[str, int]], + dao_classes: Dict[str, Any], +) -> FeatureAvailability: + """Detect available features dynamically from menus. + + Queries the FAB security manager for menu items accessible to the + current user. + """ + accessible_menus: list[str] = [] + + try: + from superset import security_manager + + menu_names = security_manager.user_view_menu_names("menu_access") + accessible_menus = sorted(menu_names) + except Exception as exc: + logger.debug("Could not retrieve accessible menus: %s", exc) + + return FeatureAvailability( + accessible_menus=accessible_menus, + ) diff --git a/superset/mcp_service/system/tool/get_instance_info.py b/superset/mcp_service/system/tool/get_instance_info.py index 8d383ae167f..5aadd36ecbd 100644 --- a/superset/mcp_service/system/tool/get_instance_info.py +++ b/superset/mcp_service/system/tool/get_instance_info.py @@ -35,6 +35,7 @@ from superset.mcp_service.system.schemas import ( 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, @@ -61,6 +62,7 @@ _instance_info_core = InstanceInfoCore( "dashboard_breakdown": calculate_dashboard_breakdown, "database_breakdown": calculate_database_breakdown, "popular_content": calculate_popular_content, + "feature_availability": calculate_feature_availability, }, time_windows={ "recent": 7, diff --git a/tests/unit_tests/mcp_service/system/test_system_utils.py b/tests/unit_tests/mcp_service/system/test_system_utils.py new file mode 100644 index 00000000000..8b3536dd689 --- /dev/null +++ b/tests/unit_tests/mcp_service/system/test_system_utils.py @@ -0,0 +1,60 @@ +# 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 system-level utility functions.""" + +from unittest.mock import MagicMock, patch + +from superset.mcp_service.system.system_utils import calculate_feature_availability + + +def test_calculate_feature_availability_returns_menus(): + """Test that accessible menus are returned.""" + mock_sm = MagicMock() + mock_sm.user_view_menu_names.return_value = { + "SQL Lab", + "Dashboards", + "Charts", + } + + with patch("superset.security_manager", mock_sm): + result = calculate_feature_availability({}, {}, {}) + + assert result.accessible_menus == ["Charts", "Dashboards", "SQL Lab"] + mock_sm.user_view_menu_names.assert_called_once_with("menu_access") + + +def test_calculate_feature_availability_empty_when_no_context(): + """Test graceful fallback when security manager is unavailable.""" + broken_sm = MagicMock() + broken_sm.user_view_menu_names.side_effect = RuntimeError("no ctx") + + with patch("superset.security_manager", broken_sm): + result = calculate_feature_availability({}, {}, {}) + + assert result.accessible_menus == [] + + +def test_calculate_feature_availability_menus_sorted(): + """Test that accessible menus are returned in sorted order.""" + mock_sm = MagicMock() + mock_sm.user_view_menu_names.return_value = {"Zzz", "Aaa", "Mmm"} + + with patch("superset.security_manager", mock_sm): + result = calculate_feature_availability({}, {}, {}) + + assert result.accessible_menus == ["Aaa", "Mmm", "Zzz"] 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 91d8fbc6e8b..74000ae8945 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 @@ -60,6 +60,7 @@ def _make_instance_info(**kwargs): from superset.mcp_service.system.schemas import ( DashboardBreakdown, DatabaseBreakdown, + FeatureAvailability, InstanceSummary, PopularContent, RecentActivity, @@ -93,6 +94,7 @@ def _make_instance_info(**kwargs): ), "database_breakdown": DatabaseBreakdown(by_type={}), "popular_content": PopularContent(top_tags=[], top_creators=[]), + "feature_availability": FeatureAvailability(), "timestamp": datetime.now(timezone.utc), } defaults.update(kwargs) diff --git a/tests/unit_tests/mcp_service/test_mcp_config.py b/tests/unit_tests/mcp_service/test_mcp_config.py index 6b466706d07..73f8cf45cbf 100644 --- a/tests/unit_tests/mcp_service/test_mcp_config.py +++ b/tests/unit_tests/mcp_service/test_mcp_config.py @@ -55,6 +55,15 @@ def test_get_default_instructions_with_enterprise_branding(): assert "execute_sql" in instructions +def test_get_default_instructions_mentions_feature_availability(): + """Test that instructions direct LLMs to get_instance_info for features.""" + instructions = get_default_instructions() + + assert "get_instance_info" in instructions + assert "Feature Availability" in instructions + assert "accessible menus" in instructions + + def test_init_fastmcp_server_with_default_app_name(): """Test that default APP_NAME produces Superset branding.""" # Mock Flask app config with default APP_NAME