You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2026/04/24 08:12:58 UTC
(superset) branch msyavuz/feat/mcp-chart-info-dashboard-filters created (now d2cf7c9e59e)
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a change to branch msyavuz/feat/mcp-chart-info-dashboard-filters
in repository https://gitbox.apache.org/repos/asf/superset.git
at d2cf7c9e59e feat(mcp): include applied dashboard filters in get_chart_info
This branch includes the following new commits:
new d2cf7c9e59e feat(mcp): include applied dashboard filters in get_chart_info
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: feat(mcp): include applied dashboard filters in get_chart_info
Posted by ms...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a commit to branch msyavuz/feat/mcp-chart-info-dashboard-filters
in repository https://gitbox.apache.org/repos/asf/superset.git
commit d2cf7c9e59ea92b7f91e37ba55cfe10b4e58110e
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Fri Apr 24 11:12:42 2026 +0300
feat(mcp): include applied dashboard filters in get_chart_info
Adds optional dashboard_id to get_chart_info; when provided, the response's
filters.dashboard_filters lists native filters in scope for the chart with
column, operator, value, and status. Reuses the existing scope resolver in
superset.charts.data.dashboard_filter_context.
Co-Authored-By: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
superset/mcp_service/chart/chart_helpers.py | 114 ++++++++-
superset/mcp_service/chart/schemas.py | 50 ++++
superset/mcp_service/chart/tool/get_chart_info.py | 51 +++-
.../mcp_service/chart/tool/test_get_chart_info.py | 262 +++++++++++++++++++++
4 files changed, 475 insertions(+), 2 deletions(-)
diff --git a/superset/mcp_service/chart/chart_helpers.py b/superset/mcp_service/chart/chart_helpers.py
index 05477e76eee..a2cae194c06 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -26,10 +26,11 @@ URL parameter extraction. Config mapping logic lives in chart_utils.py.
from __future__ import annotations
import logging
-from typing import TYPE_CHECKING
+from typing import Any, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
if TYPE_CHECKING:
+ from superset.mcp_service.chart.schemas import AppliedDashboardFilter
from superset.models.slice import Slice
logger = logging.getLogger(__name__)
@@ -79,3 +80,114 @@ def extract_form_data_key_from_url(url: str | None) -> str | None:
parsed = urlparse(url)
values = parse_qs(parsed.query).get("form_data_key", [])
return values[0] if values else None
+
+
+def _match_adhoc_by_subject(
+ adhoc_filters: Any, column: str | None
+) -> tuple[str | None, Any] | None:
+ if not column or not isinstance(adhoc_filters, list):
+ return None
+ for af in adhoc_filters:
+ if isinstance(af, dict) and af.get("subject") == column:
+ return af.get("operator"), af.get("comparator")
+ return None
+
+
+def _match_legacy_by_col(
+ legacy_filters: Any, column: str | None
+) -> tuple[str | None, Any] | None:
+ if not column or not isinstance(legacy_filters, list):
+ return None
+ for f in legacy_filters:
+ if isinstance(f, dict) and f.get("col") == column:
+ return f.get("op"), f.get("val")
+ return None
+
+
+def _resolve_filter_operator_and_value(
+ extra_form_data: dict[str, Any] | None,
+ column: str | None,
+) -> tuple[str | None, Any]:
+ """Pull operator and value for a dashboard filter from its
+ default extra_form_data, matching on target column where applicable."""
+ if not extra_form_data:
+ return None, None
+
+ if match := _match_adhoc_by_subject(extra_form_data.get("adhoc_filters"), column):
+ return match
+ if match := _match_legacy_by_col(extra_form_data.get("filters"), column):
+ return match
+ # Temporal filters contribute time_range with no target column
+ if time_range := extra_form_data.get("time_range"):
+ return "TIME_RANGE", time_range
+ return None, None
+
+
+def build_applied_dashboard_filters(
+ dashboard_id: int, chart_id: int
+) -> list[AppliedDashboardFilter]:
+ """Resolve dashboard-level native filters in scope for a chart.
+
+ Validates that the dashboard exists, the caller has access, and the chart
+ is on the dashboard. Returns one AppliedDashboardFilter per non-DIVIDER
+ native filter whose scope includes the chart, populated with the filter's
+ default operator and value.
+
+ Raises ValueError if the dashboard is missing or the chart is not on it,
+ and SupersetSecurityException if the caller cannot access the dashboard.
+ """
+ # Local imports avoid circular deps at module load
+ from superset import db, security_manager
+ from superset.charts.data.dashboard_filter_context import (
+ _extract_filter_extra_form_data,
+ _get_filter_target_column,
+ _is_filter_in_scope_for_chart,
+ )
+ from superset.mcp_service.chart.schemas import AppliedDashboardFilter
+ from superset.models.dashboard import Dashboard
+ from superset.utils import json
+
+ dashboard = db.session.query(Dashboard).filter_by(id=dashboard_id).one_or_none()
+ if not dashboard:
+ raise ValueError(f"Dashboard {dashboard_id} not found")
+
+ security_manager.raise_for_access(dashboard=dashboard)
+
+ slice_ids = {slc.id for slc in dashboard.slices}
+ if chart_id not in slice_ids:
+ raise ValueError(f"Chart {chart_id} is not on dashboard {dashboard_id}")
+
+ metadata = json.loads(dashboard.json_metadata or "{}")
+ native_filter_config = metadata.get("native_filter_configuration", [])
+ if not isinstance(native_filter_config, list):
+ return []
+ position_json = json.loads(dashboard.position_json or "{}")
+ if not isinstance(position_json, dict):
+ position_json = {}
+
+ applied: list[AppliedDashboardFilter] = []
+ for flt in native_filter_config:
+ if not isinstance(flt, dict):
+ continue
+ if flt.get("type", "") == "DIVIDER":
+ continue
+ if not _is_filter_in_scope_for_chart(flt, chart_id, position_json):
+ continue
+
+ extra_form_data, status = _extract_filter_extra_form_data(flt)
+ column = _get_filter_target_column(flt)
+ operator, value = _resolve_filter_operator_and_value(extra_form_data, column)
+
+ applied.append(
+ AppliedDashboardFilter(
+ id=flt.get("id"),
+ name=flt.get("name"),
+ filter_type=flt.get("filterType"),
+ column=column,
+ operator=operator,
+ value=value,
+ status=status.value,
+ )
+ )
+
+ return applied
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index e309d528d76..26a54833802 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -283,6 +283,15 @@ class GetChartInfoRequest(BaseModel):
"Can be used alone (without identifier) for unsaved charts."
),
)
+ dashboard_id: int | None = Field(
+ default=None,
+ description=(
+ "When provided, resolves dashboard-level native filters that are in "
+ "scope for this chart on the given dashboard and returns them under "
+ "filters.dashboard_filters. Requires the chart to be on the dashboard "
+ "and the caller to have dashboard access."
+ ),
+ )
@model_validator(mode="after")
def validate_identifier_or_form_data_key(self) -> "GetChartInfoRequest":
@@ -1978,6 +1987,38 @@ class AdhocFilter(BaseModel):
model_config = ConfigDict(extra="ignore")
+class AppliedDashboardFilter(BaseModel):
+ """A dashboard-level native filter resolved against a specific chart.
+
+ Returned when get_chart_info is called with a dashboard_id. Values come
+ from the filter's default state on the saved dashboard (not a permalink).
+ """
+
+ id: str | None = Field(None, description="Native filter ID")
+ name: str | None = Field(None, description="Filter display name")
+ filter_type: str | None = Field(
+ None, description="Native filter type (e.g. filter_select, filter_range)"
+ )
+ column: str | None = Field(None, description="Target column the filter applies to")
+ operator: str | None = Field(
+ None,
+ description=(
+ "Filter operator as stored in extra_form_data (e.g. 'IN', '==', 'LIKE', "
+ "or 'TIME_RANGE' for temporal filters with no target column)"
+ ),
+ )
+ value: Any | None = Field(
+ None, description="Filter value(s) from the default data mask"
+ )
+ status: str = Field(
+ ...,
+ description=(
+ "Whether the filter contributes to the chart query: 'applied', "
+ "'not_applied', or 'not_applied_uses_default_to_first_item_prequery'"
+ ),
+ )
+
+
class ChartFiltersInfo(BaseModel):
"""Structured representation of all filters applied to a chart."""
@@ -2019,6 +2060,15 @@ class ChartFiltersInfo(BaseModel):
None,
description="Custom HAVING clause applied to the chart query",
)
+ dashboard_filters: List[AppliedDashboardFilter] = Field(
+ default_factory=list,
+ description=(
+ "Dashboard-level native filters in scope for this chart on the "
+ "dashboard passed via get_chart_info's dashboard_id argument. Empty "
+ "when no dashboard_id was provided or no native filter targets this "
+ "chart."
+ ),
+ )
# Rebuild ChartInfo so Pydantic can resolve the ChartFiltersInfo forward reference.
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py b/superset/mcp_service/chart/tool/get_chart_info.py
index fb8720a1c1b..69f71455057 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -25,11 +25,16 @@ from fastmcp import Context
from sqlalchemy.orm import subqueryload
from superset_core.mcp.decorators import tool, ToolAnnotations
+from superset.exceptions import SupersetSecurityException
from superset.extensions import event_logger
-from superset.mcp_service.chart.chart_helpers import get_cached_form_data
+from superset.mcp_service.chart.chart_helpers import (
+ build_applied_dashboard_filters,
+ get_cached_form_data,
+)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.schemas import (
ChartError,
+ ChartFiltersInfo,
ChartInfo,
extract_filters_from_form_data,
GetChartInfoRequest,
@@ -74,6 +79,34 @@ def _build_unsaved_chart_info(form_data_key: str) -> ChartInfo | ChartError:
)
+async def _attach_dashboard_filters(
+ result: ChartInfo, dashboard_id: int, ctx: Context
+) -> ChartError | None:
+ """Resolve dashboard-scoped native filters and attach them to result.filters.
+
+ Returns a ChartError to surface to the caller on validation / access
+ failures, or None on success (including the no-filters case).
+ """
+ if not result.id:
+ return None
+ with event_logger.log_context(action="mcp.get_chart_info.dashboard_filters"):
+ try:
+ dashboard_filters = build_applied_dashboard_filters(dashboard_id, result.id)
+ except ValueError as exc:
+ await ctx.warning("Chart not on dashboard: %s" % (str(exc),))
+ return ChartError(error=str(exc), error_type="ChartNotOnDashboard")
+ except SupersetSecurityException as exc:
+ await ctx.warning("Dashboard not accessible: %s" % (str(exc),))
+ return ChartError(error=str(exc), error_type="DashboardNotAccessible")
+
+ if dashboard_filters:
+ if result.filters is None:
+ result.filters = ChartFiltersInfo(dashboard_filters=dashboard_filters)
+ else:
+ result.filters.dashboard_filters = dashboard_filters
+ return None
+
+
def _apply_unsaved_state_override(result: ChartInfo, form_data_key: str) -> None:
"""Override a ChartInfo's form_data with cached unsaved state."""
from superset.utils import json as utils_json
@@ -146,6 +179,17 @@ async def get_chart_info(
}
```
+ With dashboard context to resolve applied dashboard-level filters:
+ ```json
+ {
+ "identifier": 123,
+ "dashboard_id": 45
+ }
+ ```
+ When dashboard_id is provided, the response's filters.dashboard_filters
+ lists native filters (with column, operator, and value) that are in scope
+ for this chart on that dashboard.
+
Returns chart details including name, type, and URL.
"""
from superset.daos.chart import ChartDAO
@@ -226,6 +270,11 @@ async def get_chart_info(
# Log any warnings (e.g., virtual dataset warnings)
for warning in validation_result.warnings:
await ctx.warning("Dataset warning: %s" % (warning,))
+
+ if request.dashboard_id:
+ error = await _attach_dashboard_filters(result, request.dashboard_id, ctx)
+ if error is not None:
+ return error
else:
await ctx.warning("Chart retrieval failed: error=%s" % (str(result),))
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_info.py b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_info.py
new file mode 100644
index 00000000000..68ec66e33ed
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_info.py
@@ -0,0 +1,262 @@
+# 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 get_chart_info MCP tool: dashboard-filter resolution path.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.mcp_service.chart.chart_helpers import (
+ _resolve_filter_operator_and_value,
+ build_applied_dashboard_filters,
+)
+from superset.mcp_service.chart.schemas import GetChartInfoRequest
+
+
+class TestGetChartInfoRequestSchema:
+ def test_dashboard_id_optional(self):
+ request = GetChartInfoRequest(identifier=1)
+ assert request.dashboard_id is None
+
+ def test_dashboard_id_accepted(self):
+ request = GetChartInfoRequest(identifier=1, dashboard_id=42)
+ assert request.dashboard_id == 42
+
+
+class TestResolveFilterOperatorAndValue:
+ def test_matches_adhoc_filter_by_subject(self):
+ efd = {
+ "adhoc_filters": [
+ {
+ "subject": "country",
+ "operator": "IN",
+ "comparator": ["US", "CA"],
+ }
+ ]
+ }
+ assert _resolve_filter_operator_and_value(efd, "country") == (
+ "IN",
+ ["US", "CA"],
+ )
+
+ def test_matches_legacy_filter_by_col(self):
+ efd = {"filters": [{"col": "state", "op": "==", "val": "NY"}]}
+ assert _resolve_filter_operator_and_value(efd, "state") == ("==", "NY")
+
+ def test_time_range_when_no_column(self):
+ efd = {"time_range": "Last 7 days"}
+ assert _resolve_filter_operator_and_value(efd, None) == (
+ "TIME_RANGE",
+ "Last 7 days",
+ )
+
+ def test_column_not_in_extra_form_data(self):
+ efd = {
+ "adhoc_filters": [{"subject": "other", "operator": "==", "comparator": 1}]
+ }
+ assert _resolve_filter_operator_and_value(efd, "country") == (None, None)
+
+ def test_none_extra_form_data(self):
+ assert _resolve_filter_operator_and_value(None, "country") == (None, None)
+
+ def test_ignores_non_dict_entries(self):
+ efd = {
+ "adhoc_filters": ["not-a-dict", None],
+ "filters": [42, "foo"],
+ }
+ assert _resolve_filter_operator_and_value(efd, "country") == (None, None)
+
+
+class TestBuildAppliedDashboardFilters:
+ """The helper validates access, checks chart-on-dashboard, iterates
+ native filters, resolves scope, and maps each to AppliedDashboardFilter."""
+
+ def _make_dashboard(self, json_metadata=None, position_json=None, slice_ids=None):
+ dashboard = MagicMock()
+ dashboard.json_metadata = json_metadata or "{}"
+ dashboard.position_json = position_json or "{}"
+ dashboard.slices = [MagicMock(id=sid) for sid in (slice_ids or [])]
+ return dashboard
+
+ def test_chart_not_on_dashboard_raises(self):
+ dashboard = self._make_dashboard(slice_ids=[2, 3])
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = dashboard # noqa: E501
+ with pytest.raises(ValueError, match="not on dashboard"):
+ build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ def test_dashboard_not_found_raises(self):
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = None # noqa: E501
+ with pytest.raises(ValueError, match="not found"):
+ build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ def test_in_scope_filter_with_static_default(self):
+ native_filter = {
+ "id": "NATIVE_FILTER-1",
+ "name": "Country",
+ "type": "NATIVE_FILTER",
+ "filterType": "filter_select",
+ "chartsInScope": [1],
+ "targets": [{"column": {"name": "country"}, "datasetId": 7}],
+ "defaultDataMask": {
+ "filterState": {"value": ["US"]},
+ "extraFormData": {
+ "adhoc_filters": [
+ {
+ "subject": "country",
+ "operator": "IN",
+ "comparator": ["US"],
+ }
+ ]
+ },
+ },
+ }
+ dashboard = self._make_dashboard(
+ json_metadata='{"native_filter_configuration": %s}'
+ % _json(native_filter_list=[native_filter]),
+ slice_ids=[1],
+ )
+
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = dashboard # noqa: E501
+ result = build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ assert len(result) == 1
+ flt = result[0]
+ assert flt.id == "NATIVE_FILTER-1"
+ assert flt.name == "Country"
+ assert flt.filter_type == "filter_select"
+ assert flt.column == "country"
+ assert flt.operator == "IN"
+ assert flt.value == ["US"]
+ assert flt.status == "applied"
+
+ def test_excluded_chart_filter_skipped(self):
+ native_filter = {
+ "id": "NATIVE_FILTER-1",
+ "name": "Region",
+ "type": "NATIVE_FILTER",
+ "filterType": "filter_select",
+ "chartsInScope": [2, 3], # chart 1 excluded
+ "targets": [{"column": {"name": "region"}, "datasetId": 7}],
+ "defaultDataMask": {
+ "filterState": {"value": ["NA"]},
+ "extraFormData": {
+ "filters": [{"col": "region", "op": "==", "val": "NA"}]
+ },
+ },
+ }
+ dashboard = self._make_dashboard(
+ json_metadata='{"native_filter_configuration": %s}'
+ % _json(native_filter_list=[native_filter]),
+ slice_ids=[1],
+ )
+
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = dashboard # noqa: E501
+ result = build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ assert result == []
+
+ def test_default_to_first_item_marks_prequery(self):
+ native_filter = {
+ "id": "NATIVE_FILTER-1",
+ "name": "Region",
+ "type": "NATIVE_FILTER",
+ "filterType": "filter_select",
+ "chartsInScope": [1],
+ "targets": [{"column": {"name": "region"}, "datasetId": 7}],
+ "controlValues": {"defaultToFirstItem": True},
+ "defaultDataMask": {},
+ }
+ dashboard = self._make_dashboard(
+ json_metadata='{"native_filter_configuration": %s}'
+ % _json(native_filter_list=[native_filter]),
+ slice_ids=[1],
+ )
+
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = dashboard # noqa: E501
+ result = build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ assert len(result) == 1
+ assert result[0].status == "not_applied_uses_default_to_first_item_prequery"
+ assert result[0].operator is None
+ assert result[0].value is None
+
+ def test_divider_entry_skipped(self):
+ divider = {
+ "id": "DIVIDER-1",
+ "name": "Section header",
+ "type": "DIVIDER",
+ }
+ dashboard = self._make_dashboard(
+ json_metadata='{"native_filter_configuration": %s}'
+ % _json(native_filter_list=[divider]),
+ slice_ids=[1],
+ )
+
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = dashboard # noqa: E501
+ result = build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ assert result == []
+
+ def test_no_native_filters_returns_empty_list(self):
+ dashboard = self._make_dashboard(
+ json_metadata="{}",
+ slice_ids=[1],
+ )
+
+ with (
+ patch("superset.db") as mock_db,
+ patch("superset.security_manager"),
+ ):
+ mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value = dashboard # noqa: E501
+ result = build_applied_dashboard_filters(dashboard_id=10, chart_id=1)
+
+ assert result == []
+
+
+def _json(native_filter_list):
+ """Serialize a native_filter list as JSON string for embedding in
+ json_metadata fixtures without escaping issues."""
+ from superset.utils import json
+
+ return json.dumps(native_filter_list)