feat(mcp): MCP service implementation (PRs 3-9 consolidated) (#35877)

This commit is contained in:
Amin Ghadersohi
2025-11-01 02:33:21 +11:00
committed by GitHub
parent 30d584afd1
commit fee4e7d8e2
106 changed files with 21826 additions and 223 deletions

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,450 @@
# 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.
"""
Unit tests for dashboard generation MCP tools
"""
import logging
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
from superset.utils import json
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
"""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
def _mock_chart(id: int = 1, slice_name: str = "Test Chart") -> Mock:
"""Create a mock chart object."""
chart = Mock()
chart.id = id
chart.slice_name = slice_name
chart.uuid = f"chart-uuid-{id}"
return chart
def _mock_dashboard(id: int = 1, title: str = "Test Dashboard") -> Mock:
"""Create a mock dashboard object."""
dashboard = Mock()
dashboard.id = id
dashboard.dashboard_title = title
dashboard.slug = f"test-dashboard-{id}"
dashboard.description = "Test dashboard description"
dashboard.published = True
dashboard.created_on = "2024-01-01"
dashboard.changed_on = "2024-01-01"
dashboard.created_by = Mock()
dashboard.created_by.username = "test_user"
dashboard.changed_by = Mock()
dashboard.changed_by.username = "test_user"
dashboard.uuid = f"dashboard-uuid-{id}"
dashboard.slices = []
dashboard.owners = [] # Add missing owners attribute
dashboard.tags = [] # Add missing tags attribute
return dashboard
class TestGenerateDashboard:
"""Tests for generate_dashboard MCP tool."""
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_basic(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test basic dashboard generation with valid charts."""
# Mock database query for charts
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=1, slice_name="Sales Chart"),
_mock_chart(id=2, slice_name="Revenue Chart"),
]
mock_db_session.query.return_value = mock_query
# Mock dashboard creation
mock_dashboard = _mock_dashboard(id=10, title="Analytics Dashboard")
mock_create_command.return_value.run.return_value = mock_dashboard
request = {
"chart_ids": [1, 2],
"dashboard_title": "Analytics Dashboard",
"description": "Dashboard for analytics",
"published": True,
}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.data.error is None
assert result.data.dashboard is not None
assert result.data.dashboard.id == 10
assert result.data.dashboard.dashboard_title == "Analytics Dashboard"
assert result.data.dashboard.chart_count == 2
assert "/superset/dashboard/10/" in result.data.dashboard_url
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_missing_charts(self, mock_db_session, mcp_server):
"""Test error handling when some charts don't exist."""
# Mock database query returning only chart 1 (chart 2 missing)
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=1),
# Chart 2 is missing from the result
]
mock_db_session.query.return_value = mock_query
request = {"chart_ids": [1, 2], "dashboard_title": "Test Dashboard"}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.data.error is not None
assert "Charts not found: [2]" in result.data.error
assert result.data.dashboard is None
assert result.data.dashboard_url is None
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_single_chart(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test dashboard generation with a single chart."""
# Mock database query for single chart
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=5, slice_name="Single Chart")]
mock_db_session.query.return_value = mock_query
mock_dashboard = _mock_dashboard(id=20, title="Single Chart Dashboard")
mock_create_command.return_value.run.return_value = mock_dashboard
request = {
"chart_ids": [5],
"dashboard_title": "Single Chart Dashboard",
"published": False,
}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.data.error is None
assert result.data.dashboard.chart_count == 1
assert result.data.dashboard.published is True # From mock
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_many_charts(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test dashboard generation with many charts (grid layout)."""
# Mock 6 charts
chart_ids = list(range(1, 7))
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=i, slice_name=f"Chart {i}") for i in chart_ids
]
mock_db_session.query.return_value = mock_query
mock_dashboard = _mock_dashboard(id=30, title="Multi Chart Dashboard")
mock_create_command.return_value.run.return_value = mock_dashboard
request = {"chart_ids": chart_ids, "dashboard_title": "Multi Chart Dashboard"}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.data.error is None
assert result.data.dashboard.chart_count == 6
# Verify CreateDashboardCommand was called with proper layout
mock_create_command.assert_called_once()
call_args = mock_create_command.call_args[0][0]
# Check position_json contains proper layout
position_json = json.loads(call_args["position_json"])
assert "ROOT_ID" in position_json
assert "GRID_ID" in position_json
assert "DASHBOARD_VERSION_KEY" in position_json
assert position_json["DASHBOARD_VERSION_KEY"] == "v2"
# ROOT should only contain GRID
assert position_json["ROOT_ID"]["children"] == ["GRID_ID"]
# GRID should contain rows (6 charts = 3 rows in 2-chart layout)
grid_children = position_json["GRID_ID"]["children"]
assert len(grid_children) == 3
# Check each chart has proper structure
for i, chart_id in enumerate(chart_ids):
chart_key = f"CHART-{chart_id}"
row_index = i // 2 # 2 charts per row
row_key = f"ROW-{row_index}"
# Chart should exist
assert chart_key in position_json
chart_data = position_json[chart_key]
assert chart_data["type"] == "CHART"
assert "meta" in chart_data
assert chart_data["meta"]["chartId"] == chart_id
# Row should exist and contain charts (up to 2 per row)
assert row_key in position_json
row_data = position_json[row_key]
assert row_data["type"] == "ROW"
assert chart_key in row_data["children"]
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_creation_failure(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test error handling when dashboard creation fails."""
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=1)]
mock_db_session.query.return_value = mock_query
mock_create_command.return_value.run.side_effect = Exception("Creation failed")
request = {"chart_ids": [1], "dashboard_title": "Failed Dashboard"}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.data.error is not None
assert "Failed to create dashboard" in result.data.error
assert result.data.dashboard is None
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_minimal_request(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test dashboard generation with minimal required parameters."""
# Mock database query for single chart
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=3)]
mock_db_session.query.return_value = mock_query
mock_dashboard = _mock_dashboard(id=40, title="Minimal Dashboard")
mock_create_command.return_value.run.return_value = mock_dashboard
request = {
"chart_ids": [3],
"dashboard_title": "Minimal Dashboard",
# No description, published defaults to True
}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.data.error is None
assert result.data.dashboard.dashboard_title == "Minimal Dashboard"
# Check that description was not included in call
call_args = mock_create_command.call_args[0][0]
assert call_args["published"] is True # Default value
assert (
"description" not in call_args or call_args.get("description") is None
)
class TestAddChartToExistingDashboard:
"""Tests for add_chart_to_existing_dashboard MCP tool."""
@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_to_dashboard_basic(
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
):
"""Test adding a chart to an existing dashboard."""
# Mock existing dashboard with some charts
mock_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
mock_dashboard.slices = [Mock(id=10), Mock(id=20)] # Existing charts
mock_dashboard.position_json = json.dumps(
{
"ROOT_ID": {
"children": ["CHART-10", "CHART-20"],
"id": "ROOT_ID",
"type": "ROOT",
},
"CHART-10": {"id": "CHART-10", "type": "CHART", "parents": ["ROOT_ID"]},
"CHART-10_POSITION": {"h": 16, "w": 24, "x": 0, "y": 0},
"CHART-20": {"id": "CHART-20", "type": "CHART", "parents": ["ROOT_ID"]},
"CHART-20_POSITION": {"h": 16, "w": 24, "x": 24, "y": 0},
}
)
mock_find_dashboard.return_value = mock_dashboard
# Mock chart to add
mock_chart = _mock_chart(id=30, slice_name="New Chart")
mock_db_session.get.return_value = mock_chart
# Mock updated dashboard
updated_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
updated_dashboard.slices = [Mock(id=10), Mock(id=20), Mock(id=30)]
mock_update_command.return_value.run.return_value = updated_dashboard
request = {"dashboard_id": 1, "chart_id": 30}
async with Client(mcp_server) as client:
result = await client.call_tool(
"add_chart_to_existing_dashboard", {"request": request}
)
assert result.data.error is None
assert result.data.dashboard is not None
assert result.data.dashboard.chart_count == 3
assert result.data.position is not None
assert "row" in result.data.position # Should have row info
assert "chart_key" in result.data.position
assert "/superset/dashboard/1/" in result.data.dashboard_url
@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):
"""Test error when dashboard doesn't exist."""
mock_find_dashboard.return_value = None
request = {"dashboard_id": 999, "chart_id": 1}
async with Client(mcp_server) as client:
result = await client.call_tool(
"add_chart_to_existing_dashboard", {"request": request}
)
assert result.data.error is not None
assert "Dashboard with ID 999 not found" in result.data.error
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_add_chart_chart_not_found(
self, mock_db_session, mock_find_dashboard, mcp_server
):
"""Test error when chart doesn't exist."""
mock_find_dashboard.return_value = _mock_dashboard()
mock_db_session.get.return_value = None
request = {"dashboard_id": 1, "chart_id": 999}
async with Client(mcp_server) as client:
result = await client.call_tool(
"add_chart_to_existing_dashboard", {"request": request}
)
assert result.data.error is not None
assert "Chart with ID 999 not found" in result.data.error
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_add_chart_already_in_dashboard(
self, mock_db_session, mock_find_dashboard, mcp_server
):
"""Test error when chart is already in dashboard."""
mock_dashboard = _mock_dashboard()
mock_dashboard.slices = [Mock(id=5)] # Chart 5 already exists
mock_find_dashboard.return_value = mock_dashboard
mock_db_session.get.return_value = _mock_chart(id=5)
request = {"dashboard_id": 1, "chart_id": 5}
async with Client(mcp_server) as client:
result = await client.call_tool(
"add_chart_to_existing_dashboard", {"request": request}
)
assert result.data.error is not None
assert "Chart 5 is already in dashboard 1" in result.data.error
@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_empty_dashboard(
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
):
"""Test adding chart to dashboard with no existing layout."""
mock_dashboard = _mock_dashboard(id=2)
mock_dashboard.slices = []
mock_dashboard.position_json = "{}" # Empty layout
mock_find_dashboard.return_value = mock_dashboard
mock_chart = _mock_chart(id=15)
mock_db_session.get.return_value = mock_chart
updated_dashboard = _mock_dashboard(id=2)
updated_dashboard.slices = [Mock(id=15)]
mock_update_command.return_value.run.return_value = updated_dashboard
request = {"dashboard_id": 2, "chart_id": 15}
async with Client(mcp_server) as client:
result = await client.call_tool(
"add_chart_to_existing_dashboard", {"request": request}
)
assert result.data.error is None
assert "row" in result.data.position # Should have row info
assert result.data.position.get("row") == 0 # First row
# Verify update was called with proper layout structure
call_args = mock_update_command.call_args[0][1]
layout = json.loads(call_args["position_json"])
assert "ROOT_ID" in layout
assert "GRID_ID" in layout
assert "ROW-0" in layout
assert "CHART-15" in layout

View File

@@ -0,0 +1,573 @@
# 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.
"""
Unit tests for MCP dashboard tools (list_dashboards, get_dashboard_info)
"""
import logging
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from superset.mcp_service.app import mcp
from superset.mcp_service.dashboard.schemas import (
ListDashboardsRequest,
)
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
"""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
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_basic(mock_list, mcp_server):
dashboard = Mock()
dashboard.id = 1
dashboard.dashboard_title = "Test Dashboard"
dashboard.slug = "test-dashboard"
dashboard.url = "/dashboard/1"
dashboard.published = True
dashboard.changed_by_name = "admin"
dashboard.changed_on = None
dashboard.changed_on_humanized = None
dashboard.created_by_name = "admin"
dashboard.created_on = None
dashboard.created_on_humanized = None
dashboard.tags = []
dashboard.owners = []
dashboard.slices = []
dashboard.description = None
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.is_managed_externally = False
dashboard.external_url = None
dashboard.uuid = "test-dashboard-uuid-1"
dashboard.thumbnail_url = None
dashboard.roles = []
dashboard.charts = []
dashboard._mapping = {
"id": dashboard.id,
"dashboard_title": dashboard.dashboard_title,
"slug": dashboard.slug,
"url": dashboard.url,
"published": dashboard.published,
"changed_by_name": dashboard.changed_by_name,
"changed_on": dashboard.changed_on,
"changed_on_humanized": dashboard.changed_on_humanized,
"created_by_name": dashboard.created_by_name,
"created_on": dashboard.created_on,
"created_on_humanized": dashboard.created_on_humanized,
"tags": dashboard.tags,
"owners": dashboard.owners,
"charts": [],
}
mock_list.return_value = ([dashboard], 1)
async with Client(mcp_server) as client:
request = ListDashboardsRequest(page=1, page_size=10)
result = await client.call_tool(
"list_dashboards", {"request": request.model_dump()}
)
dashboards = result.data.dashboards
assert len(dashboards) == 1
assert dashboards[0].dashboard_title == "Test Dashboard"
assert dashboards[0].uuid == "test-dashboard-uuid-1"
assert dashboards[0].slug == "test-dashboard"
assert dashboards[0].published is True
# Verify UUID and slug are in default columns
assert "uuid" in result.data.columns_requested
assert "slug" in result.data.columns_requested
assert "uuid" in result.data.columns_loaded
assert "slug" in result.data.columns_loaded
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_with_filters(mock_list, mcp_server):
dashboard = Mock()
dashboard.id = 1
dashboard.dashboard_title = "Filtered Dashboard"
dashboard.slug = "filtered-dashboard"
dashboard.url = "/dashboard/2"
dashboard.published = True
dashboard.changed_by_name = "admin"
dashboard.changed_on = None
dashboard.changed_on_humanized = None
dashboard.created_by_name = "admin"
dashboard.created_on = None
dashboard.created_on_humanized = None
dashboard.tags = []
dashboard.owners = []
dashboard.slices = []
dashboard.description = None
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.is_managed_externally = False
dashboard.external_url = None
dashboard.uuid = None
dashboard.thumbnail_url = None
dashboard.roles = []
dashboard.charts = []
dashboard._mapping = {
"id": dashboard.id,
"dashboard_title": dashboard.dashboard_title,
"slug": dashboard.slug,
"url": dashboard.url,
"published": dashboard.published,
"changed_by_name": dashboard.changed_by_name,
"changed_on": dashboard.changed_on,
"changed_on_humanized": dashboard.changed_on_humanized,
"created_by_name": dashboard.created_by_name,
"created_on": dashboard.created_on,
"created_on_humanized": dashboard.created_on_humanized,
"tags": dashboard.tags,
"owners": dashboard.owners,
"charts": [],
}
mock_list.return_value = ([dashboard], 1)
async with Client(mcp_server) as client:
filters = [
{"col": "dashboard_title", "opr": "sw", "value": "Sales"},
{"col": "published", "opr": "eq", "value": True},
]
request = ListDashboardsRequest(
filters=filters,
select_columns=["id", "dashboard_title"],
order_column="changed_on",
order_direction="desc",
page=1,
page_size=50,
)
result = await client.call_tool(
"list_dashboards", {"request": request.model_dump()}
)
assert result.data.count == 1
assert result.data.dashboards[0].dashboard_title == "Filtered Dashboard"
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_with_string_filters(mock_list, mcp_server):
mock_list.return_value = ([], 0)
async with Client(mcp_server) as client: # noqa: F841
filters = '[{"col": "dashboard_title", "opr": "sw", "value": "Sales"}]'
# Test that string filters cause validation error at schema level
with pytest.raises(ValueError, match="validation error"):
ListDashboardsRequest(filters=filters) # noqa: F841
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_api_error(mock_list, mcp_server):
mock_list.side_effect = ToolError("API request failed")
async with Client(mcp_server) as client:
with pytest.raises(ToolError) as excinfo: # noqa: PT012
request = ListDashboardsRequest()
await client.call_tool("list_dashboards", {"request": request.model_dump()})
assert "API request failed" in str(excinfo.value)
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_with_search(mock_list, mcp_server):
dashboard = Mock()
dashboard.id = 1
dashboard.dashboard_title = "search_dashboard"
dashboard.slug = "search-dashboard"
dashboard.url = "/dashboard/1"
dashboard.published = True
dashboard.changed_by_name = "admin"
dashboard.changed_on = None
dashboard.changed_on_humanized = None
dashboard.created_by_name = "admin"
dashboard.created_on = None
dashboard.created_on_humanized = None
dashboard.tags = []
dashboard.owners = []
dashboard.slices = []
dashboard.description = None
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.is_managed_externally = False
dashboard.external_url = None
dashboard.uuid = None
dashboard.thumbnail_url = None
dashboard.roles = []
dashboard.charts = []
dashboard._mapping = {
"id": dashboard.id,
"dashboard_title": dashboard.dashboard_title,
"slug": dashboard.slug,
"url": dashboard.url,
"published": dashboard.published,
"changed_by_name": dashboard.changed_by_name,
"changed_on": dashboard.changed_on,
"changed_on_humanized": dashboard.changed_on_humanized,
"created_by_name": dashboard.created_by_name,
"created_on": dashboard.created_on,
"created_on_humanized": dashboard.created_on_humanized,
"tags": dashboard.tags,
"owners": dashboard.owners,
"charts": [],
}
mock_list.return_value = ([dashboard], 1)
async with Client(mcp_server) as client:
request = ListDashboardsRequest(search="search_dashboard")
result = await client.call_tool(
"list_dashboards", {"request": request.model_dump()}
)
assert result.data.count == 1
assert result.data.dashboards[0].dashboard_title == "search_dashboard"
args, kwargs = mock_list.call_args
assert kwargs["search"] == "search_dashboard"
assert "dashboard_title" in kwargs["search_columns"]
assert "slug" in kwargs["search_columns"]
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_with_simple_filters(mock_list, mcp_server):
mock_list.return_value = ([], 0)
async with Client(mcp_server) as client:
filters = [
{"col": "dashboard_title", "opr": "eq", "value": "Sales"},
{"col": "published", "opr": "eq", "value": True},
]
request = ListDashboardsRequest(filters=filters)
result = await client.call_tool(
"list_dashboards", {"request": request.model_dump()}
)
assert hasattr(result.data, "count")
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_info_success(mock_info, mcp_server):
dashboard = Mock()
dashboard.id = 1
dashboard.dashboard_title = "Test Dashboard"
dashboard.slug = "test-dashboard"
dashboard.description = "Test description"
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.thumbnail_url = None
dashboard.created_on_humanized = None
dashboard.changed_on_humanized = None
dashboard.slices = []
dashboard.owners = []
dashboard.tags = []
dashboard.roles = []
dashboard.charts = []
dashboard._mapping = {
"id": dashboard.id,
"dashboard_title": dashboard.dashboard_title,
"slug": dashboard.slug,
"url": dashboard.url,
"published": dashboard.published,
"changed_by_name": dashboard.changed_by_name,
"changed_on": dashboard.changed_on,
"changed_on_humanized": dashboard.changed_on_humanized,
"created_by_name": dashboard.created_by_name,
"created_on": dashboard.created_on,
"created_on_humanized": dashboard.created_on_humanized,
"tags": dashboard.tags,
"owners": dashboard.owners,
"charts": [],
}
mock_info.return_value = dashboard # Only the dashboard object
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_info", {"request": {"identifier": 1}}
)
assert result.data["dashboard_title"] == "Test Dashboard"
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_info_not_found(mock_info, mcp_server):
mock_info.return_value = None # Not found returns None
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_info", {"request": {"identifier": 999}}
)
assert result.data["error_type"] == "not_found"
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_info_access_denied(mock_info, mcp_server):
mock_info.return_value = None # Access denied returns None
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_info", {"request": {"identifier": 1}}
)
assert result.data["error_type"] == "not_found"
# TODO (Phase 3+): Add tests for get_dashboard_available_filters tool
@patch("superset.mcp_service.mcp_core.ModelGetInfoCore._find_object")
@pytest.mark.asyncio
async def test_get_dashboard_info_by_uuid(mock_find_object, mcp_server):
"""Test getting dashboard info using UUID identifier."""
dashboard = Mock()
dashboard.id = 1
dashboard.dashboard_title = "Test Dashboard UUID"
dashboard.slug = "test-dashboard-uuid"
dashboard.description = "Test description"
dashboard.css = ""
dashboard.certified_by = None
dashboard.certification_details = None
dashboard.json_metadata = "{}"
dashboard.position_json = "{}"
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 = "c3d4e5f6-g7h8-9012-cdef-gh3456789012"
dashboard.url = "/dashboard/1"
dashboard.thumbnail_url = None
dashboard.created_on_humanized = "2 days ago"
dashboard.changed_on_humanized = "1 day ago"
dashboard.slices = []
dashboard.owners = []
dashboard.tags = []
dashboard.roles = []
mock_find_object.return_value = dashboard
async with Client(mcp_server) as client:
uuid_str = "c3d4e5f6-g7h8-9012-cdef-gh3456789012"
result = await client.call_tool(
"get_dashboard_info", {"request": {"identifier": uuid_str}}
)
assert result.data["dashboard_title"] == "Test Dashboard UUID"
@patch("superset.mcp_service.mcp_core.ModelGetInfoCore._find_object")
@pytest.mark.asyncio
async def test_get_dashboard_info_by_slug(mock_find_object, mcp_server):
"""Test getting dashboard info using slug identifier."""
dashboard = Mock()
dashboard.id = 2
dashboard.dashboard_title = "Test Dashboard Slug"
dashboard.slug = "test-dashboard-slug"
dashboard.description = "Test description"
dashboard.css = ""
dashboard.certified_by = None
dashboard.certification_details = None
dashboard.json_metadata = "{}"
dashboard.position_json = "{}"
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 = "d4e5f6g7-h8i9-0123-defg-hi4567890123"
dashboard.url = "/dashboard/2"
dashboard.thumbnail_url = None
dashboard.created_on_humanized = "2 days ago"
dashboard.changed_on_humanized = "1 day ago"
dashboard.slices = []
dashboard.owners = []
dashboard.tags = []
dashboard.roles = []
mock_find_object.return_value = dashboard
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_info", {"request": {"identifier": "test-dashboard-slug"}}
)
assert result.data["dashboard_title"] == "Test Dashboard Slug"
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_custom_uuid_slug_columns(mock_list, mcp_server):
"""Test that custom column selection includes UUID and slug when explicitly
requested."""
dashboard = Mock()
dashboard.id = 1
dashboard.dashboard_title = "Custom Columns Dashboard"
dashboard.slug = "custom-dashboard"
dashboard.uuid = "test-custom-uuid-123"
dashboard.url = "/dashboard/1"
dashboard.published = True
dashboard.changed_by_name = "admin"
dashboard.changed_on = None
dashboard.changed_on_humanized = None
dashboard.created_by_name = "admin"
dashboard.created_on = None
dashboard.created_on_humanized = None
dashboard.tags = []
dashboard.owners = []
dashboard.slices = []
dashboard.description = None
dashboard.css = None
dashboard.certified_by = None
dashboard.certification_details = None
dashboard.json_metadata = None
dashboard.position_json = None
dashboard.is_managed_externally = False
dashboard.external_url = None
dashboard.thumbnail_url = None
dashboard.roles = []
dashboard.charts = []
dashboard._mapping = {
"id": dashboard.id,
"dashboard_title": dashboard.dashboard_title,
"slug": dashboard.slug,
"uuid": dashboard.uuid,
"url": dashboard.url,
"published": dashboard.published,
"changed_by_name": dashboard.changed_by_name,
"changed_on": dashboard.changed_on,
"changed_on_humanized": dashboard.changed_on_humanized,
"created_by_name": dashboard.created_by_name,
"created_on": dashboard.created_on,
"created_on_humanized": dashboard.created_on_humanized,
"tags": dashboard.tags,
"owners": dashboard.owners,
"charts": [],
}
mock_list.return_value = ([dashboard], 1)
async with Client(mcp_server) as client:
request = ListDashboardsRequest(
select_columns=["id", "dashboard_title", "uuid", "slug"],
page=1,
page_size=10,
)
result = await client.call_tool(
"list_dashboards", {"request": request.model_dump()}
)
dashboards = result.data.dashboards
assert len(dashboards) == 1
assert dashboards[0].uuid == "test-custom-uuid-123"
assert dashboards[0].slug == "custom-dashboard"
# Verify custom columns include UUID and slug
assert "uuid" in result.data.columns_requested
assert "slug" in result.data.columns_requested
assert "uuid" in result.data.columns_loaded
assert "slug" in result.data.columns_loaded
class TestDashboardSortableColumns:
"""Test sortable columns configuration for dashboard tools."""
def test_dashboard_sortable_columns_definition(self):
"""Test that dashboard sortable columns are properly defined."""
from superset.mcp_service.dashboard.tool.list_dashboards import (
SORTABLE_DASHBOARD_COLUMNS,
)
assert SORTABLE_DASHBOARD_COLUMNS == [
"id",
"dashboard_title",
"slug",
"published",
"changed_on",
"created_on",
]
# Ensure no computed properties are included
assert "changed_on_delta_humanized" not in SORTABLE_DASHBOARD_COLUMNS
assert "changed_by_name" not in SORTABLE_DASHBOARD_COLUMNS
assert "uuid" not in SORTABLE_DASHBOARD_COLUMNS
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_with_valid_order_column(self, mock_list, mcp_server):
"""Test list_dashboards with valid order column."""
mock_list.return_value = ([], 0)
async with Client(mcp_server) as client:
# Test with valid sortable column
request = ListDashboardsRequest(
order_column="dashboard_title", order_direction="desc"
)
result = await client.call_tool(
"list_dashboards", {"request": request.model_dump()}
)
# Verify the DAO was called with the correct order column
mock_list.assert_called_once()
call_args = mock_list.call_args[1]
assert call_args["order_column"] == "dashboard_title"
assert call_args["order_direction"] == "desc"
# Verify the result
assert result.data.count == 0
assert result.data.dashboards == []
def test_sortable_columns_in_docstring(self):
"""Test that sortable columns are documented in tool docstring."""
from superset.mcp_service.dashboard.tool.list_dashboards import (
list_dashboards,
SORTABLE_DASHBOARD_COLUMNS,
)
# Check list_dashboards docstring (stored in description after @mcp.tool)
assert hasattr(list_dashboards, "description")
assert "Sortable columns for order_column:" in list_dashboards.description
for col in SORTABLE_DASHBOARD_COLUMNS:
assert col in list_dashboards.description