Files
superset2/superset/mcp_service/chart/validation/schema_validator.py
Amin Ghadersohi f5ba09f7af 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-05-15 00:59:54 +00:00

244 lines
10 KiB
Python
Executable File

# 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.
"""
Unified schema validation for chart configurations.
Consolidates pre-validation, schema validation, and error enhancement.
"""
import logging
from typing import Any, Dict, Tuple
from pydantic import ValidationError as PydanticValidationError
from superset.mcp_service.chart.schemas import (
GenerateChartRequest,
)
from superset.mcp_service.common.error_schemas import ChartGenerationError
logger = logging.getLogger(__name__)
class SchemaValidator:
"""Unified schema validator with pre-validation and enhanced error messages."""
@staticmethod
def validate_request(
request_data: Dict[str, Any],
) -> Tuple[bool, GenerateChartRequest | None, ChartGenerationError | None]:
"""
Validate request data with pre-validation and enhanced error handling.
Returns:
Tuple of (is_valid, parsed_request, error)
"""
# Pre-validate to catch common issues early
is_valid, error = SchemaValidator._pre_validate(request_data)
if not is_valid:
return False, None, error
# Try Pydantic validation
try:
request = GenerateChartRequest(**request_data)
return True, request, None
except PydanticValidationError as e:
# Enhance the error message
error = SchemaValidator._enhance_validation_error(e, request_data)
return False, None, error
@staticmethod
def _pre_validate(
data: Dict[str, Any],
) -> Tuple[bool, ChartGenerationError | None]:
"""Pre-validate request data before Pydantic processing."""
if not isinstance(data, dict):
return False, ChartGenerationError(
error_type="invalid_request_format",
message="Request must be a JSON object",
details="The request body must be a valid JSON object, not a string "
"or array",
suggestions=[
"Ensure you're sending a JSON object with 'dataset_id' and "
"'config' fields",
"Check that Content-Type header is set to 'application/json'",
],
error_code="INVALID_REQUEST_FORMAT",
)
# Check for required top-level fields
if "dataset_id" not in data:
return False, ChartGenerationError(
error_type="missing_dataset_id",
message="Missing required field: dataset_id",
details="The 'dataset_id' field is required to identify which dataset "
"to use",
suggestions=[
"Add 'dataset_id' field with the ID of your dataset",
"Use list_datasets tool to find available dataset IDs",
"Example: {'dataset_id': 1, 'config': {...}}",
],
error_code="MISSING_DATASET_ID",
)
if "config" not in data:
return False, ChartGenerationError(
error_type="missing_config",
message="Missing required field: config",
details="The 'config' field is required to specify chart configuration",
suggestions=[
"Add 'config' field with chart type and settings",
"Example: {'dataset_id': 1, 'config': {'chart_type': 'xy', ...}}",
],
error_code="MISSING_CONFIG",
)
config = data.get("config", {})
if not isinstance(config, dict):
return False, ChartGenerationError(
error_type="invalid_config_format",
message="Config must be a JSON object",
details="The 'config' field must be a valid JSON object with chart "
"settings",
suggestions=[
"Ensure config is an object, not a string or array",
"Example: 'config': {'chart_type': 'xy', 'x': {...}, 'y': [...]}",
],
error_code="INVALID_CONFIG_FORMAT",
)
# Check chart_type early
chart_type = config.get("chart_type")
if not chart_type:
return False, ChartGenerationError(
error_type="missing_chart_type",
message="Missing required field: chart_type",
details="Chart configuration must specify 'chart_type'",
suggestions=[
"Add 'chart_type': 'xy' for line/bar/area/scatter charts",
"Add 'chart_type': 'table' for table visualizations",
"Add 'chart_type': 'pie' for pie or donut charts",
"Add 'chart_type': 'pivot_table' for interactive pivot tables",
"Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
"Add 'chart_type': 'handlebars' for custom HTML template charts",
"Add 'chart_type': 'big_number' for big number display",
"Example: 'config': {'chart_type': 'xy', ...}",
],
error_code="MISSING_CHART_TYPE",
)
return SchemaValidator._pre_validate_chart_type(chart_type, config)
@staticmethod
def _pre_validate_chart_type(
chart_type: str,
config: Dict[str, Any],
) -> Tuple[bool, ChartGenerationError | None]:
"""Validate chart type and dispatch to plugin pre-validation."""
from superset.mcp_service.chart.registry import get_registry
registry = get_registry()
if not isinstance(chart_type, str) or not registry.is_registered(chart_type):
valid_types = ", ".join(registry.all_types())
return False, ChartGenerationError(
error_type="invalid_chart_type",
message=f"Invalid chart_type: '{chart_type}'",
details=f"Chart type '{chart_type}' is not supported. "
f"Must be one of: {valid_types}",
suggestions=[
"Use 'chart_type': 'xy' for line, bar, area, or scatter charts",
"Use 'chart_type': 'table' for tabular data display",
"Use 'chart_type': 'pie' for pie or donut charts",
"Use 'chart_type': 'pivot_table' for interactive pivot tables",
"Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
"Use 'chart_type': 'handlebars' for custom HTML template charts",
"Use 'chart_type': 'big_number' for big number display",
"Check spelling and ensure lowercase",
],
error_code="INVALID_CHART_TYPE",
)
if not registry.is_enabled(chart_type):
valid_types = ", ".join(registry.all_types())
return False, ChartGenerationError(
error_type="disabled_chart_type",
message=f"Chart type '{chart_type}' is not enabled on this instance",
details=f"Chart type '{chart_type}' is registered but has been "
f"disabled by the operator. "
f"Enabled chart types: {valid_types}",
suggestions=[
f"Use one of the enabled chart types: {valid_types}",
"Contact your administrator if you believe this is an error",
],
error_code="DISABLED_CHART_TYPE",
)
plugin = registry.get(chart_type)
if plugin is None:
return False, ChartGenerationError(
error_type="invalid_chart_type",
message=f"Chart type '{chart_type}' has no registered plugin",
details="Internal error: chart type is listed but has no plugin",
suggestions=["Use a supported chart_type"],
error_code="INVALID_CHART_TYPE",
)
if (error := plugin.pre_validate(config)) is not None:
return False, error
return True, None
@staticmethod
def _enhance_validation_error(
error: PydanticValidationError, request_data: Dict[str, Any]
) -> ChartGenerationError:
"""Convert Pydantic validation errors to user-friendly messages."""
errors = error.errors()
# Check for discriminated union errors (generic "'table' was expected")
for err in errors:
if err.get("type") == "union_tag_invalid" or "discriminator" in str(
err.get("ctx", {})
):
from superset.mcp_service.chart.registry import get_registry
chart_type = request_data.get("config", {}).get("chart_type", "")
plugin = get_registry().get(chart_type)
if plugin is not None:
hint = plugin.schema_error_hint()
if hint is not None:
return hint
# Default enhanced error
error_details = []
for err in errors[:3]: # Show first 3 errors
loc = " -> ".join(str(location) for location in err.get("loc", []))
msg = err.get("msg", "Validation failed")
error_details.append(f"{loc}: {msg}" if loc else msg)
return ChartGenerationError(
error_type="validation_error",
message="Chart configuration validation failed",
details="; ".join(error_details) or "Invalid chart configuration structure",
suggestions=[
"Check that all required fields are present",
"Ensure field types match the schema",
"Use get_dataset_info to verify column names",
"Refer to the API documentation for field requirements",
],
error_code="VALIDATION_ERROR",
)