Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/admin_docs/configuration/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,11 @@ Specifying a tool name that does not exist logs a warning at startup and is othe

## Disabling chart type plugins

The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `pivot_table`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.

Gauge data inspection and JSON/CSV/XLSX exports preserve source groups, including NULL and non-finite metric values, using the same row-count and completeness semantics as other chart types. The default XLSX exporter writes non-finite floats as `nan`, `inf`, or `-inf` text, matching CSV and distinguishing them from blank NULL cells. Only previews and compile checks skip NULL, NaN, non-finite, and other nonnumeric dial values while preserving finite groups. In preview/compile paths, a nonempty result containing no finite dial returns an error; genuinely empty results retain the no-data behavior. Compile checks inspect the configured dial limit (at most 10), rather than only two groups. Grouped Vega previews center each needle within its own facet and extend it to the midpoint of the dial band. Automatic all-zero ranges span 0–1. Empty or whitespace-only temporal ranges, like `No filter`, are neutral sentinels; missing or non-string native temporal comparators are invalid.

Gauge previews retain data-derived automatic bounds even when configured interval thresholds extend beyond the visible range; Vega previews clip those thresholds into adjacent, non-overlapping bands. Explicit min/max bounds still constrain intervals. All-zero data with both bounds omitted uses a nondegenerate preview range of 0–1. On same-dataset Gauge updates, clearing or changing the dashboard temporal binding preserves user-authored temporal ranges; dataset rebinds discard old query roles and filters. The native `"No filter"` sentinel is a neutral dashboard temporal binding, not a conflicting restriction. Duplicate generated neutral bindings self-heal on updates without removing user-authored ranges. Restating the existing dataset ID without a config is allowed; changing the Gauge dataset still requires a complete config. Native restrictive temporal filters that conflict with the top-level range or another restrictive temporal filter are rejected rather than silently dropped. Unsupported native filter operators are rejected with an indexed error. Native `TEMPORAL_RANGE` adhoc filters require an explicit string comparator; `"No filter"`, empty strings, and whitespace represent an unrestricted range.

### Static deny-list

Expand Down
2 changes: 2 additions & 0 deletions superset/mcp_service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ def get_default_instructions(
- chart_type="table": Data table for detailed views
- chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)
- chart_type="gauge": Gauge/dial for one numeric metric, optionally grouped
into up to 10 dials (native viz_type is "gauge_chart")
- chart_type="pivot_table": OSS Pivot Table for cross-tabulation
- chart_type="interactive_pivot": Extension-provided AG Grid Interactive Pivot Table.
This type is distinct from pivot_table/pivot_table_v2 and is available only
Expand Down
182 changes: 182 additions & 0 deletions superset/mcp_service/chart/chart_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,168 @@ def merge_interactive_pivot_ui_config(
new_form_data["pivot_table_state"] = {**existing_state, **new_state}


_GAUGE_FORM_DATA_FIELD_MAP: dict[str, str] = {
"groupby": "groupby",
"sort_by_metric": "sort_by_metric",
"row_limit": "row_limit",
"min_val": "min_val",
"max_val": "max_val",
"color_scheme": "color_scheme",
"font_size": "font_size",
"number_format": "number_format",
"currency_format": "currency_format",
"value_formatter": "value_formatter",
"start_angle": "start_angle",
"end_angle": "end_angle",
"show_pointer": "show_pointer",
"animation": "animation",
"show_axis_tick": "show_axis_tick",
"show_split_line": "show_split_line",
"split_number": "split_number",
"show_progress": "show_progress",
"overlap": "overlap",
"round_cap": "round_cap",
"intervals": "intervals",
"interval_color_indices": "interval_color_indices",
"time_range": "time_range",
"granularity_sqla": "granularity_sqla",
}

_GAUGE_PRESENTATION_FORM_DATA_KEYS = frozenset(
{
"min_val",
"max_val",
"color_scheme",
"font_size",
"number_format",
"currency_format",
"value_formatter",
"start_angle",
"end_angle",
"show_pointer",
"animation",
"show_axis_tick",
"show_split_line",
"split_number",
"show_progress",
"overlap",
"round_cap",
"intervals",
"interval_color_indices",
}
)


def _without_generated_gauge_time_filter(
form_data: dict[str, Any],
) -> list[Any]:
"""Return cached filters without the mapper-owned temporal binding."""
generated_subject = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
return [
filter_
for filter_ in form_data.get("adhoc_filters", [])
if not (
generated_subject
and isinstance(filter_, dict)
and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
and filter_.get("subject") == generated_subject
Comment thread
aminghadersohi marked this conversation as resolved.
and filter_.get("comparator") == NO_TIME_RANGE
and filter_.get("clause") == "WHERE"
and filter_.get("expressionType") == "SIMPLE"
)
]


def merge_chart_form_data( # noqa: C901
existing_form_data: dict[str, Any],
new_form_data: dict[str, Any],
config: ChartConfig,
*,
dataset_rebind: bool = False,
) -> dict[str, Any]:
"""Merge update form_data while preserving omitted same-viz controls.

A viz-type change never inherits old controls. Dataset rebinds similarly
drop query roles and filters; Gauge presentation controls remain safe to
preserve because they do not reference the old dataset.
"""
if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
return dict(new_form_data)
if not isinstance(config, GaugeChartConfig):
if dataset_rebind:
return dict(new_form_data)
merged = {**existing_form_data, **new_form_data}
if getattr(config, "filters", None) == []:
merged.pop("adhoc_filters", None)
return merged

fields_set = config.model_fields_set
if dataset_rebind:
merged = {
key: value
for key, value in existing_form_data.items()
if key in _GAUGE_PRESENTATION_FORM_DATA_KEYS
}
else:
merged = dict(existing_form_data)

patch = dict(new_form_data)
for config_field, form_data_field in _GAUGE_FORM_DATA_FIELD_MAP.items():
if config_field not in fields_set:
patch.pop(form_data_field, None)

filters_explicit = "filters" in fields_set
temporal_explicit = "temporal_column" in fields_set
if not filters_explicit:
if temporal_explicit:
preserved_filters = (
[]
if dataset_rebind
else _without_generated_gauge_time_filter(existing_form_data)
)
generated_filters = patch.get("adhoc_filters", [])
patch["adhoc_filters"] = [*preserved_filters, *generated_filters]
if config.temporal_column is None:
patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
else:
patch.pop("adhoc_filters", None)
patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)

merged.update(patch)
if filters_explicit:
if config.filters == [] and not (temporal_explicit and config.temporal_column):
merged.pop("adhoc_filters", None)
if MCP_DASHBOARD_TIME_FILTER_SUBJECT not in patch:
merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
for nullable_field in (
"color_scheme",
"currency_format",
"time_range",
"granularity_sqla",
):
if nullable_field in fields_set and getattr(config, nullable_field) is None:
merged.pop(_GAUGE_FORM_DATA_FIELD_MAP[nullable_field], None)
if temporal_explicit and config.temporal_column is None:
merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
if subject := merged.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT):
seen_binding = False
filters = []
for filter_ in merged.get("adhoc_filters", []):
is_binding = (
isinstance(filter_, dict)
and filter_.get("subject") == subject
and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
and filter_.get("comparator") == NO_TIME_RANGE
and filter_.get("clause") == "WHERE"
and filter_.get("expressionType") == "SIMPLE"
)
if not is_binding or not seen_binding:
filters.append(filter_)
seen_binding = seen_binding or is_binding
merged["adhoc_filters"] = filters
return merged


def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
"""Create a metric object for a column with enhanced validation.

Expand Down Expand Up @@ -1062,7 +1224,27 @@ def map_gauge_config(config: GaugeChartConfig) -> Dict[str, Any]:
"min_val": config.min_val,
"max_val": config.max_val,
"color_scheme": config.color_scheme or "supersetColors",
"font_size": config.font_size,
"number_format": config.number_format,
"value_formatter": config.value_formatter,
"start_angle": config.start_angle,
"end_angle": config.end_angle,
"show_pointer": config.show_pointer,
"animation": config.animation,
"show_axis_tick": config.show_axis_tick,
"show_split_line": config.show_split_line,
"split_number": config.split_number,
"show_progress": config.show_progress,
"overlap": config.overlap,
"round_cap": config.round_cap,
"intervals": config.intervals,
"interval_color_indices": config.interval_color_indices,
}
if config.time_range is not None:
form_data["time_range"] = config.time_range
if config.granularity_sqla is not None:
form_data["granularity_sqla"] = config.granularity_sqla
add_currency_format(form_data, config.currency_format)
_add_adhoc_filters(form_data, config.filters)
return form_data

Expand Down
105 changes: 60 additions & 45 deletions superset/mcp_service/chart/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,24 @@
from __future__ import annotations

import logging
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal

from sqlalchemy.exc import SQLAlchemyError

from superset.commands.exceptions import CommandException
from superset.errors import SupersetErrorType
from superset.mcp_service.chart.query_result import (
normalize_gauge_query_result,
query_result_failure,
)
from superset.mcp_service.chart.schemas import ChartError
from superset.mcp_service.chart.validation.dataset_validator import (
AmbiguousDatasetReferenceError,
build_dataset_context_from_orm,
DatasetValidator,
resolve_dataset_reference,
)
from superset.mcp_service.common.error_schemas import (
ChartGenerationError,
Expand Down Expand Up @@ -116,39 +124,21 @@ def _compile_chart(
ChartDataCacheLoadError,
ChartDataQueryFailedError,
)
from superset.common.query_context_factory import QueryContextFactory
from superset.mcp_service.chart.chart_utils import adhoc_filters_to_query_filters
from superset.mcp_service.chart.preview_utils import _build_query_columns
from superset.mcp_service.chart.chart_helpers import (
build_query_context_from_form_data,
)

try:
columns = _build_query_columns(form_data)
query_filters = adhoc_filters_to_query_filters(
form_data.get("adhoc_filters", [])
)

# Big Number charts use singular "metric" instead of "metrics"
metrics = form_data.get("metrics", [])
if not metrics and form_data.get("metric"):
metrics = [form_data["metric"]]

# Big Number with trendline uses granularity_sqla as the time column
if not columns and form_data.get("granularity_sqla"):
columns = [form_data["granularity_sqla"]]

factory = QueryContextFactory()
query_context = factory.create(
datasource={"id": dataset_id, "type": "table"},
queries=[
{
"columns": columns,
"metrics": metrics,
"orderby": form_data.get("orderby", []),
"row_limit": 2,
"filters": query_filters,
"time_range": form_data.get("time_range", "No filter"),
}
],
form_data=form_data,
query_form_data = deepcopy(form_data)
query_form_data["datasource"] = f"{dataset_id}__table"
query_form_data["datasource_id"] = dataset_id
query_form_data["datasource_type"] = "table"
query_context = build_query_context_from_form_data(
query_form_data,
row_limit=min(10, int(form_data.get("row_limit") or 10))
if form_data.get("viz_type") == "gauge_chart"
else 2,
force=False,
)

command = ChartDataCommand(query_context)
Expand All @@ -157,16 +147,34 @@ def _compile_chart(

warnings: List[str] = []
row_count = 0
if query_failure := query_result_failure(result):
error_str = query_failure.error
return CompileResult(
success=False,
error=error_str,
error_code="CHART_COMPILE_FAILED",
tier="compile",
error_obj=_build_compile_error(error_str),
)
result = normalize_gauge_query_result(result, form_data)
if isinstance(result, ChartError):
return CompileResult(
success=False,
error=result.error,
error_code="INVALID_GAUGE_RESULT",
tier="compile",
error_obj=ChartGenerationError(
error_type=result.error_type,
message="Gauge metric query returned invalid values",
details=result.error,
suggestions=[
"Use a numeric-producing metric",
"Check the metric alias and SQL expression",
],
error_code="INVALID_GAUGE_RESULT",
),
)
for query in result.get("queries", []):
if query.get("error"):
error_str = str(query["error"])
return CompileResult(
success=False,
error=error_str,
error_code="CHART_COMPILE_FAILED",
tier="compile",
error_obj=_build_compile_error(error_str),
)
row_count += len(query.get("data", []))

return CompileResult(success=True, warnings=warnings, row_count=row_count)
Expand Down Expand Up @@ -224,9 +232,13 @@ def _adhoc_filter_column_valid(
"""
if clause == "HAVING":
return DatasetValidator._column_exists(column, dataset_context)
return any(
col["name"].lower() == column.lower()
for col in dataset_context.available_columns
return (
resolve_dataset_reference(
column,
(col["name"] for col in dataset_context.available_columns),
"physical column",
)
is not None
)


Expand Down Expand Up @@ -254,8 +266,11 @@ def _validate_adhoc_filter_columns(
if not column or not isinstance(column, str):
continue
clause = f.get("clause", "WHERE").upper()
if not _adhoc_filter_column_valid(column, clause, dataset_context):
invalid.append(column)
try:
if not _adhoc_filter_column_valid(column, clause, dataset_context):
invalid.append(column)
except AmbiguousDatasetReferenceError as ex:
return DatasetValidator._build_ambiguous_reference_error(ex)

if not invalid:
return None
Expand Down
Loading
Loading