Files
superset2/superset/mcp_service/flask_singleton.py
Amin Ghadersohi 4e0eb3a395 feat(mcp): add runtime chart plugin enable/disable via _PluginFilterConfig
Introduces a dynamic filter layer in the chart type registry so operators can
disable individual plugins (e.g. `handlebars`) without a code deploy:

- `MCP_DISABLED_CHART_PLUGINS: frozenset[str]` — static deny-list in mcp_config.py
- `MCP_CHART_PLUGIN_ENABLED_FUNC: Callable[[str], bool] | None` — dynamic hook
  for Harness/Split/per-user targeting; takes precedence over the deny-list
- Both keys are propagated through `get_mcp_config()` defaults

registry.py changes:
- `_PluginFilterConfig` frozen dataclass replaces two bare globals so
  configure() replaces them atomically (no torn reads under concurrency)
- `configure(disabled, enabled_func)` — called at app init; accepts any
  iterable for `disabled`; validates `enabled_func` is callable
- `_is_plugin_enabled()` — reads config once, fails closed on callable exception
- `get()` / `all_types()` / `is_enabled()` apply the filter at lookup time;
  `is_registered()` and `display_name_for_viz_type()` intentionally bypass it
  so callers can distinguish "unknown" vs "disabled" and existing charts still
  resolve display names for disabled viz types

schema_validator.py: two-step pre-check — `is_registered()` for unknown types,
`is_enabled()` for disabled ones, with distinct `DISABLED_CHART_TYPE` error code.

Wiring:
- `SupersetAppInitializer.configure_mcp_chart_registry()` called after
  `configure_feature_flags()` in `init_app()`
- `flask_singleton.py` re-calls `registry.configure()` after the MCP config
  overlay so MCP-specific overrides in `superset_config.py` take effect in
  standalone MCP mode

Tests: 28 cases in test_registry_filters.py covering deny-list, callable hook,
fail-closed on exception, all_types() filtering, display_name bypass, atomic
reconfigure, and configure() with list/tuple/frozenset inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:04:53 +00:00

118 lines
4.8 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.
"""
Simple module-level Flask app instance for MCP service.
Following the Stack Overflow recommendation:
"a simple module with just the instance is enough"
- The module itself acts as the singleton
- No need for complex patterns or metaclasses
- Clean and Pythonic approach
"""
import logging
from flask import current_app, Flask, has_app_context
logger = logging.getLogger(__name__)
logger.info("Creating Flask app instance for MCP service")
try:
from superset.extensions import appbuilder
# Check if appbuilder is already initialized (main Superset app is running).
# If so, reuse that app to avoid corrupting the shared appbuilder singleton.
# Calling create_app() again would re-initialize appbuilder and break views.
#
# NOTE: appbuilder.app now returns a LocalProxy to current_app (Flask-AppBuilder
# deprecation), so we can't use `appbuilder.app is not None` as that always
# returns True (compares LocalProxy object, not the resolved value).
# Instead, check if init_app was called by looking at _session.
appbuilder_initialized = appbuilder._session is not None
if appbuilder_initialized and has_app_context():
# We're in an app context (e.g., during main Superset startup),
# so we can get the actual Flask app instance from current_app
logger.info("Reusing existing Flask app from app context for MCP service")
# Use _get_current_object() to get the actual Flask app, not the LocalProxy
app = current_app._get_current_object()
elif appbuilder_initialized:
# appbuilder is initialized but we have no app context. Calling
# create_app() here would invoke appbuilder.init_app() a second
# time with a *different* Flask app, overwriting shared internal
# state (views, security manager, etc.). Fail loudly instead of
# silently corrupting the singleton.
raise RuntimeError(
"appbuilder is already initialized but no Flask app context is "
"available. Cannot call create_app() as it would re-initialize "
"appbuilder with a different Flask app instance."
)
else:
# Standalone MCP server — Superset models are deeply coupled to
# appbuilder, security_manager, event_logger, encrypted_field_factory,
# etc. so we use create_app() for full initialization rather than
# trying to init a minimal subset (which leads to cascading failures).
#
# create_app() is safe here because in standalone mode the main
# Superset web server is not running in-process.
from superset.app import create_app
from superset.mcp_service.mcp_config import get_mcp_config
logger.info("Creating fully initialized Flask app for standalone MCP service")
_mcp_app = create_app()
_mcp_app.debug = False
# Apply MCP-specific configuration on top
mcp_config = get_mcp_config(_mcp_app.config)
_mcp_app.config.update(mcp_config)
# Re-configure chart registry so MCP-specific overrides (e.g.
# MCP_DISABLED_CHART_PLUGINS set by the operator) take effect after
# the MCP config overlay. SupersetAppInitializer.configure_mcp_chart_registry()
# ran earlier with pre-overlay values; this call corrects them.
from superset.mcp_service.chart import registry as _chart_registry
_chart_registry.configure(
disabled=_mcp_app.config.get("MCP_DISABLED_CHART_PLUGINS"),
enabled_func=_mcp_app.config.get("MCP_CHART_PLUGIN_ENABLED_FUNC"),
)
with _mcp_app.app_context():
from superset.core.mcp.core_mcp_injection import (
initialize_core_mcp_dependencies,
)
initialize_core_mcp_dependencies()
app = _mcp_app
logger.info("Flask app fully initialized for standalone MCP service")
except Exception as e:
logger.error("Failed to create Flask app: %s", e)
raise
def get_flask_app() -> Flask:
"""
Get the Flask app instance.
Returns:
Flask: The module-level Flask app instance
"""
return app