Files
superset2/tests/unit_tests/mcp_service/test_mcp_server.py
2026-07-16 15:32:33 -07:00

301 lines
10 KiB
Python

# 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 MCP server EventStore creation."""
from collections.abc import Awaitable, Callable
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from starlette.requests import Request
from starlette.responses import Response
# A Starlette-style ASGI endpoint, matching FastMCP's custom_route contract.
Endpoint = Callable[[Request], Awaitable[Response]]
def test_create_event_store_returns_none_when_no_redis_url():
"""EventStore returns None when no Redis URL configured (single-pod mode)."""
config = {"CACHE_REDIS_URL": None}
from superset.mcp_service.server import create_event_store
result = create_event_store(config)
assert result is None
def test_create_event_store_returns_none_when_empty_config():
"""EventStore returns None when config has no CACHE_REDIS_URL."""
config = {}
from superset.mcp_service.server import create_event_store
result = create_event_store(config)
assert result is None
def test_create_event_store_creates_event_store_with_redis():
"""EventStore is created with Redis backend when URL is configured."""
config = {
"CACHE_REDIS_URL": "redis://localhost:6379/0",
"event_store_max_events": 50,
"event_store_ttl": 1800,
}
mock_redis_store = MagicMock()
mock_event_store = MagicMock()
with patch(
"superset.mcp_service.server._create_redis_store",
return_value=mock_redis_store,
) as mock_create_store:
with patch(
"fastmcp.server.event_store.EventStore",
return_value=mock_event_store,
) as mock_event_store_class:
from superset.mcp_service.server import create_event_store
result = create_event_store(config)
# Verify EventStore was created
assert result is mock_event_store
# Verify _create_redis_store was called with prefix wrapper
mock_create_store.assert_called_once_with(
config, prefix="mcp_events_", wrap=True
)
# Verify EventStore was initialized with correct params
mock_event_store_class.assert_called_once_with(
storage=mock_redis_store,
max_events_per_stream=50,
ttl=1800,
)
def test_create_event_store_uses_default_config_values():
"""EventStore uses default values when not specified in config."""
config = {
"CACHE_REDIS_URL": "redis://localhost:6379/0",
}
mock_redis_store = MagicMock()
mock_event_store = MagicMock()
with patch(
"superset.mcp_service.server._create_redis_store",
return_value=mock_redis_store,
):
with patch(
"fastmcp.server.event_store.EventStore",
return_value=mock_event_store,
) as mock_event_store_class:
from superset.mcp_service.server import create_event_store
result = create_event_store(config)
assert result is mock_event_store
# Verify defaults are used
mock_event_store_class.assert_called_once_with(
storage=mock_redis_store,
max_events_per_stream=100, # default
ttl=3600, # default
)
def test_suppress_third_party_warnings():
"""Third-party deprecation warnings filters are installed."""
import re
import warnings
from superset.mcp_service.server import _suppress_third_party_warnings
_suppress_third_party_warnings()
# Verify marshmallow DeprecationWarning filter is installed
marshmallow_filters = [
f
for f in warnings.filters
if f[0] == "ignore"
and f[2] is DeprecationWarning
and isinstance(f[3], re.Pattern)
and f[3].pattern == r"marshmallow\..*"
]
assert len(marshmallow_filters) >= 1, (
"Expected marshmallow DeprecationWarning filter"
)
# Verify google FutureWarning filter is installed
google_filters = [
f
for f in warnings.filters
if f[0] == "ignore"
and f[2] is FutureWarning
and isinstance(f[3], re.Pattern)
and f[3].pattern == r"google\..*"
]
assert len(google_filters) >= 1, "Expected google FutureWarning filter"
def test_create_event_store_returns_none_when_redis_store_fails():
"""EventStore returns None when Redis store creation fails."""
config = {
"CACHE_REDIS_URL": "redis://localhost:6379/0",
}
with patch(
"superset.mcp_service.server._create_redis_store",
return_value=None, # Simulates Redis store creation failure
):
from superset.mcp_service.server import create_event_store
result = create_event_store(config)
assert result is None
@pytest.mark.asyncio
async def test_register_health_endpoint_registers_get_health() -> None:
"""/health is registered as an HTTP GET custom route on the MCP instance."""
from superset.mcp_service.server import _register_health_endpoint
captured: dict[str, object] = {}
def custom_route(path: str, methods: list[str]) -> Callable[[Endpoint], Endpoint]:
captured["path"] = path
captured["methods"] = methods
def decorator(fn: Endpoint) -> Endpoint:
captured["fn"] = fn
return fn
return decorator
mcp_instance = MagicMock()
mcp_instance.custom_route = custom_route
_register_health_endpoint(mcp_instance)
assert captured["path"] == "/health"
assert captured["methods"] == ["GET"]
@pytest.mark.asyncio
async def test_health_endpoint_returns_ok() -> None:
"""The /health handler returns 200 with a JSON status body."""
from superset.mcp_service.server import _register_health_endpoint
from superset.utils import json
captured: dict[str, object] = {}
def custom_route(path: str, methods: list[str]) -> Callable[[Endpoint], Endpoint]:
def decorator(fn: Endpoint) -> Endpoint:
captured["fn"] = fn
return fn
return decorator
mcp_instance = MagicMock()
mcp_instance.custom_route = custom_route
_register_health_endpoint(mcp_instance)
handler = cast(Callable[..., Awaitable[Response]], captured["fn"])
response = await handler(MagicMock(spec=Request))
assert response.status_code == 200
assert json.loads(response.body) == {"status": "ok"}
def test_create_auth_provider_uses_default_factory_for_mcp_api_key_only() -> None:
"""MCP_API_KEY_ENABLED=True should install auth even when FAB API keys are off."""
from superset.mcp_service.server import _create_auth_provider
flask_app = MagicMock()
flask_app.config.get.side_effect = lambda key, default=None: {
"MCP_AUTH_FACTORY": None,
"MCP_AUTH_ENABLED": False,
"MCP_API_KEY_ENABLED": True,
"FAB_API_KEY_ENABLED": False,
}.get(key, default)
auth_provider = MagicMock()
with patch(
"superset.mcp_service.mcp_config.create_default_mcp_auth_factory",
return_value=auth_provider,
) as create_default_mcp_auth_factory:
result = _create_auth_provider(flask_app)
assert result is auth_provider
create_default_mcp_auth_factory.assert_called_once_with(flask_app)
def test_create_auth_provider_propagates_auth_config_error() -> None:
"""A fatal auth config error must propagate, not fall through to no auth.
The default factory raises MCPAuthConfigError for an unusable auth
configuration. _create_auth_provider must re-raise it so the service fails
to start instead of silently returning None (which would run unauthenticated).
"""
from superset.mcp_service.mcp_config import MCPAuthConfigError
from superset.mcp_service.server import _create_auth_provider
flask_app = MagicMock()
flask_app.config.get.side_effect = lambda key, default=None: {
"MCP_AUTH_FACTORY": None,
"MCP_AUTH_ENABLED": True,
"MCP_API_KEY_ENABLED": False,
"FAB_API_KEY_ENABLED": False,
}.get(key, default)
with patch(
"superset.mcp_service.mcp_config.create_default_mcp_auth_factory",
side_effect=MCPAuthConfigError("MCP_JWT_AUDIENCE must be set"),
):
with pytest.raises(MCPAuthConfigError):
_create_auth_provider(flask_app)
def test_create_auth_provider_fails_closed_on_insecure_guest_secret() -> None:
"""Guest-only deployment with an insecure GUEST_TOKEN_JWT_SECRET must abort.
When only MCP_EMBEDDED_GUEST_AUTH_ENABLED is on and the default factory
raises MCPAuthConfigError (insecure default guest secret), _create_auth_provider
must re-raise it — otherwise the server would boot with no authentication.
"""
from superset.mcp_service.mcp_config import MCPAuthConfigError
from superset.mcp_service.server import _create_auth_provider
flask_app = MagicMock()
flask_app.config.get.side_effect = lambda key, default=None: {
"MCP_AUTH_FACTORY": None,
"MCP_AUTH_ENABLED": False,
"MCP_API_KEY_ENABLED": False,
"FAB_API_KEY_ENABLED": False,
"MCP_EMBEDDED_GUEST_AUTH_ENABLED": True,
}.get(key, default)
with patch(
"superset.mcp_service.mcp_config.create_default_mcp_auth_factory",
side_effect=MCPAuthConfigError(
"GUEST_TOKEN_JWT_SECRET is the insecure default"
),
):
with pytest.raises(MCPAuthConfigError):
_create_auth_provider(flask_app)