You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/07 07:13:25 UTC
(superset) branch master updated: feat(mcp): support filter-bar dividers in manage_native_filters (#45021)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new a0f0cb8ae3d feat(mcp): support filter-bar dividers in manage_native_filters (#45021)
a0f0cb8ae3d is described below
commit a0f0cb8ae3dd246c08a64e4da35873a64145e2d6
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Oct 7 18:13:17 2026 +1100
feat(mcp): support filter-bar dividers in manage_native_filters (#45021)
---
docs/admin_docs/configuration/mcp-server.mdx | 5 +
superset/mcp_service/app.py | 2 +-
superset/mcp_service/dashboard/schemas.py | 52 +++-
.../dashboard/tool/apply_dashboard_filters.py | 3 +-
.../dashboard/tool/manage_native_filters.py | 101 +++++--
.../dashboard/test_dashboard_schemas.py | 55 ++++
.../dashboard/tool/test_apply_dashboard_filters.py | 67 +++++
.../dashboard/tool/test_manage_native_filters.py | 334 +++++++++++++++++++++
.../unit_tests/mcp_service/test_tool_inventory.py | 4 +-
9 files changed, 588 insertions(+), 35 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index a9d2621196d..57b58e22577 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1779,6 +1779,11 @@ latency.
`filter_range` filter or changing its dataset/column target. Select filters
continue to accept nonnumeric columns.
+Dividers are visual separators and are always in scope. When updating a
+`divider` with `manage_native_filters`, use `name` to change its title and
+`description` to change its description; `scope_chart_ids` is rejected.
+`apply_dashboard_filters` excludes dividers from its available-filter error lists.
+
For `apply_dashboard_filters`, pass `time_grain` as a list containing at most
one datasource-supported duration (for example, `["P1D"]`). Custom engine
durations are accepted when exposed in the datasource's time-grain options,
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 3e3ad6406d8..554a4be476f 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -134,7 +134,7 @@ Dashboard Management:
- duplicate_dashboard: Duplicate an existing dashboard, optionally deep-copying its charts (requires write access)
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
- delete_dashboard: Delete a dashboard by ID/UUID/slug (requires editor rights — owner or Admin; destructive; does not delete its charts; soft-deletes to trash when the SOFT_DELETE feature flag is on, permanent otherwise)
-- manage_native_filters: Add, update, remove, or reorder native filters on a dashboard (requires write access; supports filter_select, filter_time, filter_range, and filter_timegrain)
+- manage_native_filters: Add, update, remove, or reorder native filters on a dashboard (requires write access; supports filter_select, filter_time, filter_range, filter_timegrain, and filter-bar divider)
- manage_dashboard_markdown: Add, update, or remove markdown/header/divider layout components on a dashboard (requires write access)
- apply_dashboard_filters: Apply values to a dashboard's existing native filters for the calling user and return a shareable permalink (read access; does NOT change the saved dashboard)
- remove_chart_from_dashboard: Remove a chart from an existing dashboard (requires write access)
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index 40b921b070c..bee19f18882 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -91,6 +91,7 @@ if TYPE_CHECKING:
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
+from superset.dashboards.filter_scope import _is_divider
from superset.exceptions import SupersetSecurityException
from superset.mcp_service.chart.schemas import (
BigNumberHeadline,
@@ -1729,6 +1730,11 @@ def _extract_native_filters(
name, type, and optionally targets — dropping verbose fields like controlValues,
defaultDataMask, scope, and cascadeParentIds. Restricted users keep filter
names and types, but target columns and dataset IDs are data-model metadata.
+
+ Dividers (visual separators with no dataset/column) are stored under
+ ``title`` rather than ``name`` and have no ``filterType``; both are
+ normalized here so dividers show up with a usable name and a
+ ``"divider"`` filter_type instead of ``None``/``None``.
"""
metadata = _parse_json_metadata(json_metadata_str)
if metadata is None:
@@ -1742,6 +1748,7 @@ def _extract_native_filters(
for f in native_filters:
if not isinstance(f, dict):
continue
+ is_divider = _is_divider(f)
raw_targets = f.get("targets", [])
if not isinstance(raw_targets, list):
raw_targets = []
@@ -1753,8 +1760,8 @@ def _extract_native_filters(
summaries.append(
NativeFilterSummary(
id=f.get("id"),
- name=f.get("name"),
- filter_type=f.get("filterType"),
+ name=f.get("title") if is_divider else f.get("name"),
+ filter_type="divider" if is_divider else f.get("filterType"),
targets=targets,
)
)
@@ -2432,23 +2439,50 @@ class FilterTimeGrainSpec(BaseNewFilterSpec):
)
+class DividerSpec(BaseModel):
+ """Spec for a new filter-bar divider.
+
+ A divider is a visual separator with a title and description used to
+ group related filters in the filter bar. Unlike the other filter
+ types it has no dataset, column, or chart scope.
+ """
+
+ filter_type: Literal["divider"] = Field(
+ ..., description="Discriminator - must be 'divider'"
+ )
+ name: str = Field(..., min_length=1, description="Divider title")
+ description: str = Field("", description="Optional divider description")
+
+
NewNativeFilterSpec = Annotated[
- FilterSelectSpec | FilterTimeSpec | FilterRangeSpec | FilterTimeGrainSpec,
+ FilterSelectSpec
+ | FilterTimeSpec
+ | FilterRangeSpec
+ | FilterTimeGrainSpec
+ | DividerSpec,
Field(discriminator="filter_type"),
]
class NativeFilterUpdateSpec(BaseModel):
- """Partial update for an existing native filter.
+ """Partial update for an existing native filter or divider.
Only ``id`` is required; any other provided field is merged into the
existing filter configuration. Fields that only apply to one filter
type (e.g. ``multi_select`` for filter_select, ``default_time_range``
- for filter_time) are rejected when used on the wrong filter type.
+ for filter_time) are rejected when used on the wrong filter type; for
+ a divider, every type-specific field (dataset_id, column,
+ multi_select, etc.) is rejected since it has none of them.
"""
- id: str = Field(..., min_length=1, description="ID of the filter to update")
- name: str | None = Field(None, min_length=1, description="New display name")
+ id: str = Field(
+ ..., min_length=1, description="ID of the filter or divider to update"
+ )
+ name: str | None = Field(
+ None,
+ min_length=1,
+ description="New display name (title, for a divider)",
+ )
description: str | None = Field(None, description="New description")
dataset_id: int | None = Field(
None,
@@ -2527,7 +2561,9 @@ class ManageNativeFiltersRequest(BaseModel):
description=(
"New filters to create. Supported types: filter_select "
"(dropdown), filter_time (time range), filter_range (numerical "
- "range), and filter_timegrain (time grain). filter_timecolumn "
+ "range), filter_timegrain (time grain), and divider (a "
+ "title/description-only visual separator for grouping filters "
+ "in the filter bar; no dataset or column). filter_timecolumn "
"(time column) is not yet supported by this tool."
),
)
diff --git a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
index 8b604551f7b..121c2e480d3 100644
--- a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
+++ b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
@@ -104,6 +104,7 @@ def _publish_filters_applied(dashboard_id: int, permalink_key: str) -> bool:
def _describe_filters(configs: list[dict[str, Any]]) -> str:
"""Render the dashboard's filters as a name/ID list for error messages."""
+ configs = [conf for conf in configs if conf.get("type") != "DIVIDER"]
if not configs:
return "This dashboard has no native filters."
described = ", ".join(
@@ -321,7 +322,7 @@ def _apply_one(
f"Filter '{spec.filter_name_or_id}' has no ID in the dashboard's "
"configuration and cannot be targeted."
)
- filter_type = conf.get("filterType")
+ filter_type = "divider" if conf.get("type") == "DIVIDER" else conf.get("filterType")
if filter_type not in SUPPORTED_FILTER_TYPES:
raise _FilterApplyError(
f"Filter '{spec.filter_name_or_id}' has type '{filter_type}', "
diff --git a/superset/mcp_service/dashboard/tool/manage_native_filters.py b/superset/mcp_service/dashboard/tool/manage_native_filters.py
index f5109956eb6..d2c14a58d5a 100644
--- a/superset/mcp_service/dashboard/tool/manage_native_filters.py
+++ b/superset/mcp_service/dashboard/tool/manage_native_filters.py
@@ -31,9 +31,11 @@ from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.constants import EMPTY_FILTER_SQL_EXPRESSION, NULL_STRING
+from superset.dashboards.filter_scope import _is_divider
from superset.extensions import event_logger
from superset.mcp_service.dashboard.constants import generate_id
from superset.mcp_service.dashboard.schemas import (
+ DividerSpec,
FilterRangeSpec,
FilterSelectSpec,
FilterSelectValue,
@@ -274,10 +276,29 @@ def _build_scope(
def _build_new_filter_config(
- spec: FilterSelectSpec | FilterTimeSpec | FilterRangeSpec | FilterTimeGrainSpec,
+ spec: (
+ FilterSelectSpec
+ | FilterTimeSpec
+ | FilterRangeSpec
+ | FilterTimeGrainSpec
+ | DividerSpec
+ ),
dashboard_chart_ids: list[int],
) -> dict[str, Any]:
- """Build a full native filter config dict for a new filter."""
+ """Build a full native filter (or divider) config dict for a new entry."""
+ if isinstance(spec, DividerSpec):
+ # Dividers have no filterType/targets/controlValues/cascadeParentIds;
+ # matching the frontend's stored shape (see
+ # transformDivider in filterTransformer.ts) keeps this entry
+ # indistinguishable from one created through the UI.
+ return {
+ "id": generate_id("NATIVE_FILTER_DIVIDER"),
+ "type": "DIVIDER",
+ "title": spec.name,
+ "description": spec.description,
+ "scope": {"rootPath": ["ROOT_ID"], "excluded": []},
+ }
+
base: dict[str, Any] = {
"id": generate_id("NATIVE_FILTER"),
"type": "NATIVE_FILTER",
@@ -350,9 +371,19 @@ def _build_new_filter_config(
def _validate_update_type_compat(
- spec: NativeFilterUpdateSpec, filter_type: str | None
+ spec: NativeFilterUpdateSpec, filter_type: str | None, *, is_divider: bool = False
) -> None:
- """Reject update fields that do not apply to the filter's type."""
+ """Reject update fields that do not apply to the filter's type.
+
+ Dividers have no ``filterType`` at all, so every type-specific field
+ (dataset_id, column, multi_select, ...) is rejected for them, same as
+ for any other filter type that does not declare it as allowed.
+ """
+ if is_divider and spec.scope_chart_ids is not None:
+ raise _FilterValidationError(
+ f"Divider '{spec.id}' does not support scope_chart_ids; "
+ "dividers are always in scope."
+ )
allowed = (
_TYPE_SPECIFIC_UPDATE_FIELDS.get(filter_type, frozenset())
if filter_type is not None
@@ -371,8 +402,9 @@ def _validate_update_type_compat(
if fields & set(invalid_fields)
}
)
+ type_label = "divider" if is_divider else filter_type
raise _FilterValidationError(
- f"Filter '{spec.id}' has type '{filter_type}'; fields "
+ f"Filter '{spec.id}' has type '{type_label}'; fields "
f"{invalid_fields} only apply to {', '.join(valid_types)} filters."
)
@@ -488,16 +520,23 @@ def _merge_filter_update(
existing: dict[str, Any],
dashboard_chart_ids: list[int],
) -> dict[str, Any]:
- """Merge a partial update into an existing filter config.
+ """Merge a partial update into an existing filter (or divider) config.
Returns a FULL filter config (the backend command substitutes whole
entries, it does not merge deltas).
"""
merged = copy.deepcopy(existing)
- _validate_update_type_compat(spec, merged.get("filterType"))
+ is_divider = _is_divider(merged)
+ _validate_update_type_compat(spec, merged.get("filterType"), is_divider=is_divider)
if spec.name is not None:
- merged["name"] = spec.name
+ # Dividers store their display text under "title", not "name";
+ # writing "name" here would silently fail to update what the
+ # filter bar actually renders.
+ if is_divider:
+ merged["title"] = spec.name
+ else:
+ merged["name"] = spec.name
if spec.description is not None:
merged["description"] = spec.description
if spec.scope_chart_ids is not None:
@@ -510,35 +549,46 @@ def _merge_filter_update(
previous_target
)
- control_values = dict(merged.get("controlValues") or {})
- for field, control_key in _CONTROL_VALUE_FIELDS.items():
- value = getattr(spec, field)
- if value is not None:
- control_values[control_key] = value
- merged["controlValues"] = control_values
+ if not is_divider:
+ # Dividers have no controlValues/defaultDataMask; type-specific
+ # fields that would populate them are already rejected above, so
+ # skip these to avoid introducing fields the frontend never writes
+ # for a divider.
+ control_values = dict(merged.get("controlValues") or {})
+ for field, control_key in _CONTROL_VALUE_FIELDS.items():
+ value = getattr(spec, field)
+ if value is not None:
+ control_values[control_key] = value
+ merged["controlValues"] = control_values
- _merge_select_default(spec, existing, merged, target_changed)
+ _merge_select_default(spec, existing, merged, target_changed)
- if spec.default_time_range is not None:
- merged["defaultDataMask"] = _time_data_mask(spec.default_time_range)
+ if spec.default_time_range is not None:
+ merged["defaultDataMask"] = _time_data_mask(spec.default_time_range)
return merged
def _filter_summary(conf: dict[str, Any]) -> NativeFilterSummary:
- """Summarize a filter config for the response.
+ """Summarize a filter (or divider) config for the response.
Returns the id, name, filterType, and non-empty targets; empty target
entries (e.g. for time filters) are dropped so the summary only lists
real dataset/column targets. All user-controlled and operational fields
preserve their application values so clients can pass them back verbatim.
+
+ Dividers store their display text under "title" and have no
+ "filterType"; both are normalized here so a divider shows up with a
+ usable name and a "divider" filter_type instead of None/None.
"""
- name = conf.get("name")
+ is_divider = _is_divider(conf)
+ name = conf.get("title") if is_divider else conf.get("name")
+ filter_type = "divider" if is_divider else conf.get("filterType")
targets = [t for t in (conf.get("targets") or []) if t]
return NativeFilterSummary(
id=conf.get("id"),
name=name,
- filter_type=conf.get("filterType"),
+ filter_type=filter_type,
targets=targets,
)
@@ -667,11 +717,14 @@ def manage_native_filters(
Supported filter types for new filters: filter_select (dropdown backed
by a dataset column), filter_time (time range), filter_range (numerical
- range backed by a dataset column), and filter_timegrain (time grain
+ range backed by a dataset column), filter_timegrain (time grain
backed by a dataset, which determines the grains it offers and
- validates selections against). filter_timecolumn (time column) is not
- yet supported. Filter IDs are generated by the server and returned in
- the response.
+ validates selections against), and divider (a title/description-only
+ visual separator with no dataset or column, used to group related
+ filters in the filter bar). filter_timecolumn (time column) is not
+ yet supported. Filter and divider IDs are generated by the server and
+ returned in the response. Dividers share the same ordering as filters,
+ so include their IDs in ``reorder`` alongside filter IDs.
Concurrency note: the filter-list snapshot used for validation is read
outside the DAO write transaction. A ``reorder`` that is valid against
diff --git a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
index 0b377b3ee06..bd0885a8020 100644
--- a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
+++ b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
@@ -504,6 +504,61 @@ class TestExtractNativeFilters:
assert _extract_native_filters("123") == []
assert _extract_native_filters('"just a string"') == []
+ @pytest.mark.parametrize(
+ "divider_id,divider_type",
+ [
+ ("NATIVE_FILTER_DIVIDER-abc123", "DIVIDER"),
+ ("NATIVE_FILTER_DIVIDER-abc123", None),
+ ("legacy-divider", "DIVIDER"),
+ ],
+ )
+ def test_divider_uses_title_as_name_and_divider_as_filter_type(
+ self, divider_id: str, divider_type: str | None
+ ) -> None:
+ """A divider stores its text under "title" and has no "filterType";
+ both must be normalized rather than surfaced as None/None."""
+ metadata = json_dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": divider_id,
+ **({"type": divider_type} if divider_type else {}),
+ "title": "Geography",
+ "description": "Location filters",
+ }
+ ]
+ }
+ )
+ result = _extract_native_filters(metadata)
+ assert len(result) == 1
+ assert result[0].id == divider_id
+ assert result[0].name == "Geography"
+ assert result[0].filter_type == "divider"
+ assert result[0].targets == []
+
+ def test_divider_alongside_regular_filter(self) -> None:
+ metadata = json_dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": "NATIVE_FILTER_DIVIDER-abc123",
+ "type": "DIVIDER",
+ "title": "Geography",
+ },
+ {
+ "id": "f1",
+ "name": "Region",
+ "filterType": "filter_select",
+ },
+ ]
+ }
+ )
+ result = _extract_native_filters(metadata)
+ assert [(r.id, r.name, r.filter_type) for r in result] == [
+ ("NATIVE_FILTER_DIVIDER-abc123", "Geography", "divider"),
+ ("f1", "Region", "filter_select"),
+ ]
+
class TestExtractCrossFiltersEnabled:
"""Tests for _extract_cross_filters_enabled helper."""
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
index bea3a6bf4b9..244322110db 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
@@ -1294,6 +1294,43 @@ async def test_unknown_filter_name_lists_the_available_filters(
assert data["permission_denied"] is False
+@pytest.mark.asyncio
+@pytest.mark.parametrize("include_filter", [True, False])
+async def test_unknown_filter_name_excludes_dividers(
+ mcp_server: object, include_filter: bool
+) -> None:
+ """Available-filter errors omit dividers, including divider-only dashboards."""
+ divider = {
+ "id": "NATIVE_FILTER_DIVIDER-geography",
+ "type": "DIVIDER",
+ "title": "Geography",
+ "description": "Location filters",
+ "scope": {"rootPath": ["ROOT_ID"], "excluded": []},
+ }
+ configs = [divider, SELECT_FILTER] if include_filter else [divider]
+ with (
+ patch(DAO_GET, return_value=_mock_dashboard(configs)),
+ patch(CREATE_PERMALINK) as create_permalink,
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "filters": [{"filter_name_or_id": "Country", "values": ["FR"]}],
+ },
+ )
+
+ assert "No filter named 'Country'" in data["error"]
+ assert "NATIVE_FILTER_DIVIDER" not in data["error"]
+ assert "(unnamed)" not in data["error"]
+ assert "type=None" not in data["error"]
+ if include_filter:
+ assert "Region (id=NATIVE_FILTER-region, type=filter_select)" in data["error"]
+ else:
+ assert "This dashboard has no native filters." in data["error"]
+ create_permalink.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_ambiguous_filter_name_asks_for_an_id(mcp_server: object) -> None:
"""Ambiguous filter name asks for an id."""
@@ -1392,6 +1429,36 @@ async def test_unsupported_filter_type_is_rejected(mcp_server: object) -> None:
assert "filter_range, filter_select, filter_time, filter_timegrain" in data["error"]
+@pytest.mark.asyncio
+async def test_divider_id_is_rejected_with_divider_type(mcp_server: object) -> None:
+ """Divider IDs report their display type without creating a permalink."""
+ divider = {
+ "id": "NATIVE_FILTER_DIVIDER-geography",
+ "type": "DIVIDER",
+ "title": "Geography",
+ "description": "Location filters",
+ "scope": {"rootPath": ["ROOT_ID"], "excluded": []},
+ }
+ with (
+ patch(DAO_GET, return_value=_mock_dashboard([divider])),
+ patch(CREATE_PERMALINK) as create_permalink,
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "filters": [{"filter_name_or_id": divider["id"], "values": ["FR"]}],
+ },
+ )
+
+ assert data["permalink_key"] is None
+ assert (
+ "Filter 'NATIVE_FILTER_DIVIDER-geography' has type 'divider', "
+ "which this tool cannot apply values to."
+ ) in data["error"]
+ create_permalink.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_duplicate_target_is_rejected(mcp_server: object) -> None:
"""Duplicate target is rejected."""
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py
index a22e06dbe69..379e8a65cb3 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py
@@ -137,6 +137,14 @@ EXISTING_TIMEGRAIN_FILTER = {
"cascadeParentIds": [],
}
+EXISTING_DIVIDER = {
+ "id": "NATIVE_FILTER_DIVIDER-existing5",
+ "type": "DIVIDER",
+ "title": "Geography",
+ "description": "Filters that target location columns",
+ "scope": {"rootPath": ["ROOT_ID"], "excluded": []},
+}
+
def _mock_dashboard(
id: int = 1,
@@ -528,6 +536,87 @@ async def test_add_filter_timegrain_with_invalid_dataset(mcp_server):
command.assert_not_called()
+@pytest.mark.asyncio
+async def test_add_divider(mcp_server):
+ """A divider has no dataset/column; it stores title+description and is
+ keyed by a NATIVE_FILTER_DIVIDER- id, matching the frontend's shape."""
+ captured: dict = {"current_config": []}
+ dashboard = _mock_dashboard(filters=[])
+
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "add": [
+ {
+ "filter_type": "divider",
+ "name": "Geography",
+ "description": "Location filters",
+ }
+ ],
+ },
+ )
+
+ assert data["error"] is None
+ new_id = data["added_filter_ids"][0]
+ assert new_id.startswith("NATIVE_FILTER_DIVIDER-")
+ config = captured["payload"]["modified"][0]
+ assert config == {
+ "id": new_id,
+ "type": "DIVIDER",
+ "title": "Geography",
+ "description": "Location filters",
+ "scope": {"rootPath": ["ROOT_ID"], "excluded": []},
+ }
+
+ summary = data["filters"][0]
+ assert summary["id"] == new_id
+ assert summary["name"] == "Geography"
+ assert summary["filter_type"] == "divider"
+ assert summary["targets"] == []
+
+
+@pytest.mark.asyncio
+async def test_add_divider_default_description(mcp_server):
+ captured: dict = {"current_config": []}
+ dashboard = _mock_dashboard(filters=[])
+
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {"dashboard_id": 1, "add": [{"filter_type": "divider", "name": "Time"}]},
+ )
+
+ assert data["error"] is None
+ config = captured["payload"]["modified"][0]
+ assert config["description"] == ""
+
+
+@pytest.mark.asyncio
+async def test_add_divider_requires_non_empty_name(mcp_server):
+ dashboard = _mock_dashboard(filters=[])
+
+ with patch(DAO_FIND_BY_ID, return_value=dashboard):
+ async with Client(mcp_server) as client:
+ with pytest.raises(Exception, match="at least 1 character"):
+ await client.call_tool(
+ "manage_native_filters",
+ {
+ "request": {
+ "dashboard_id": 1,
+ "add": [{"filter_type": "divider", "name": ""}],
+ }
+ },
+ )
+
+
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
@@ -616,6 +705,37 @@ async def test_update_time_field_on_select_filter_rejected(mcp_server):
assert "filter_time" in data["error"]
+@pytest.mark.asyncio
+async def test_update_filter_time_default_time_range(mcp_server):
+ captured: dict = {"current_config": [EXISTING_TIME_FILTER]}
+ dashboard = _mock_dashboard(filters=[EXISTING_TIME_FILTER])
+
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "update": [
+ {
+ "id": "NATIVE_FILTER-existing2",
+ "default_time_range": "Last week",
+ }
+ ],
+ },
+ )
+
+ assert data["error"] is None
+ config = captured["payload"]["modified"][0]
+ assert config["filterType"] == "filter_time"
+ assert config["defaultDataMask"] == {
+ "filterState": {"value": "Last week"},
+ "extraFormData": {"time_range": "Last week"},
+ }
+
+
@pytest.mark.asyncio
async def test_update_range_filter_target_and_empty_filter(mcp_server):
captured: dict = {"current_config": [EXISTING_RANGE_FILTER]}
@@ -780,6 +900,145 @@ async def test_update_and_remove_same_filter_rejected(mcp_server):
assert "NATIVE_FILTER-existing1" in data["error"]
+@pytest.mark.asyncio
+@pytest.mark.parametrize("include_type", [True, False])
+async def test_update_divider_title_and_description(
+ mcp_server: object, include_type: bool
+) -> None:
+ """``name`` on a divider update must land in "title", not "name" --
+ "title" is what the filter bar actually renders."""
+ divider = copy.deepcopy(EXISTING_DIVIDER)
+ if not include_type:
+ divider.pop("type")
+ captured: dict[str, Any] = {"current_config": [divider]}
+ dashboard = _mock_dashboard(filters=[divider])
+
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "update": [
+ {
+ "id": "NATIVE_FILTER_DIVIDER-existing5",
+ "name": "Region & Time",
+ "description": "Updated grouping",
+ }
+ ],
+ },
+ )
+
+ assert data["error"] is None
+ config = captured["payload"]["modified"][0]
+ assert config["title"] == "Region & Time"
+ assert config["description"] == "Updated grouping"
+ assert "name" not in config
+ # No filter-only fields leak onto a divider during an update.
+ assert "controlValues" not in config
+ assert "defaultDataMask" not in config
+
+ summary = data["filters"][0]
+ assert summary["name"] == "Region & Time"
+ assert summary["filter_type"] == "divider"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("scope_chart_ids", [[10], []])
+async def test_update_divider_rejects_scope_without_writing(
+ mcp_server: object, scope_chart_ids: list[int]
+) -> None:
+ """Divider scope updates fail validation before the write command runs."""
+ dashboard = _mock_dashboard(filters=[EXISTING_DIVIDER])
+ original_metadata = dashboard.json_metadata
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH) as command,
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "update": [
+ {"id": EXISTING_DIVIDER["id"], "scope_chart_ids": scope_chart_ids}
+ ],
+ },
+ )
+
+ assert "does not support scope_chart_ids" in data["error"]
+ assert "dividers are always in scope" in data["error"]
+ command.assert_not_called()
+ assert dashboard.json_metadata == original_metadata
+
+
+@pytest.mark.asyncio
+async def test_update_filter_scope(mcp_server: object) -> None:
+ """Regular filters retain support for chart-specific scope updates."""
+ captured: dict[str, Any] = {"current_config": [EXISTING_SELECT_FILTER]}
+ dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER], chart_ids=[10, 11])
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "update": [
+ {"id": EXISTING_SELECT_FILTER["id"], "scope_chart_ids": [10]}
+ ],
+ },
+ )
+
+ assert data["error"] is None
+ assert captured["payload"]["modified"][0]["scope"] == {
+ "rootPath": ["ROOT_ID"],
+ "excluded": [11],
+ }
+
+
+@pytest.mark.asyncio
+async def test_update_divider_rejects_dataset_field(mcp_server):
+ dashboard = _mock_dashboard(filters=[EXISTING_DIVIDER])
+
+ with patch(DAO_FIND_BY_ID, return_value=dashboard):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "update": [{"id": "NATIVE_FILTER_DIVIDER-existing5", "dataset_id": 5}],
+ },
+ )
+
+ assert "has type 'divider'" in data["error"]
+ assert "dataset_id" in data["error"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "field,value",
+ [("multi_select", True), ("default_value", ["EMEA"]), ("default_value", [])],
+)
+async def test_update_divider_rejects_select_only_field(
+ mcp_server, field: str, value: bool | list[str]
+):
+ dashboard = _mock_dashboard(filters=[EXISTING_DIVIDER])
+
+ with patch(DAO_FIND_BY_ID, return_value=dashboard):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "update": [{"id": "NATIVE_FILTER_DIVIDER-existing5", field: value}],
+ },
+ )
+
+ assert "has type 'divider'" in data["error"]
+ assert field in data["error"]
+
+
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
@@ -886,6 +1145,25 @@ async def test_remove_unknown_filter_id(mcp_server):
assert "do not exist" in data["error"]
+@pytest.mark.asyncio
+async def test_remove_divider(mcp_server):
+ captured: dict = {"current_config": [EXISTING_SELECT_FILTER, EXISTING_DIVIDER]}
+ dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER, EXISTING_DIVIDER])
+
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {"dashboard_id": 1, "remove": ["NATIVE_FILTER_DIVIDER-existing5"]},
+ )
+
+ assert data["error"] is None
+ assert data["removed_filter_ids"] == ["NATIVE_FILTER_DIVIDER-existing5"]
+ assert [f["id"] for f in data["filters"]] == ["NATIVE_FILTER-existing1"]
+
+
# ---------------------------------------------------------------------------
# Reorder
# ---------------------------------------------------------------------------
@@ -921,6 +1199,62 @@ async def test_reorder_filters(mcp_server):
]
+@pytest.mark.asyncio
+async def test_reorder_includes_divider_alongside_filters(mcp_server):
+ """Dividers share the same ordering as filters, so reorder must accept
+ (and require) their IDs right alongside filter IDs."""
+ captured: dict = {
+ "current_config": [
+ EXISTING_DIVIDER,
+ EXISTING_SELECT_FILTER,
+ EXISTING_TIME_FILTER,
+ ]
+ }
+ dashboard = _mock_dashboard(
+ filters=[EXISTING_DIVIDER, EXISTING_SELECT_FILTER, EXISTING_TIME_FILTER]
+ )
+
+ with (
+ patch(DAO_FIND_BY_ID, return_value=dashboard),
+ patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+ ):
+ data = await _call(
+ mcp_server,
+ {
+ "dashboard_id": 1,
+ "reorder": [
+ "NATIVE_FILTER-existing1",
+ "NATIVE_FILTER_DIVIDER-existing5",
+ "NATIVE_FILTER-existing2",
+ ],
+ },
+ )
+
+ assert data["error"] is None
+ assert [f["id"] for f in data["filters"]] == [
+ "NATIVE_FILTER-existing1",
+ "NATIVE_FILTER_DIVIDER-existing5",
+ "NATIVE_FILTER-existing2",
+ ]
+ assert data["filters"][1]["filter_type"] == "divider"
+
+
+@pytest.mark.asyncio
+async def test_reorder_missing_divider_rejected(mcp_server):
+ """A reorder that drops an existing divider is incomplete, same as one
+ that drops a filter -- the DAO would otherwise silently delete it."""
+ dashboard = _mock_dashboard(filters=[EXISTING_DIVIDER, EXISTING_SELECT_FILTER])
+
+ with patch(DAO_FIND_BY_ID, return_value=dashboard):
+ data = await _call(
+ mcp_server,
+ {"dashboard_id": 1, "reorder": ["NATIVE_FILTER-existing1"]},
+ )
+
+ assert "every remaining filter" in data["error"]
+ assert "NATIVE_FILTER_DIVIDER-existing5" in data["error"]
+
+
@pytest.mark.asyncio
async def test_reorder_must_include_all_filters(mcp_server):
"""The DAO silently drops filters missing from the reordered list,
diff --git a/tests/unit_tests/mcp_service/test_tool_inventory.py b/tests/unit_tests/mcp_service/test_tool_inventory.py
index 11d072296f1..84afacb0b8d 100644
--- a/tests/unit_tests/mcp_service/test_tool_inventory.py
+++ b/tests/unit_tests/mcp_service/test_tool_inventory.py
@@ -124,7 +124,9 @@ TOOL_BUDGETS = {
"manage_dashboard_markdown": 7_000,
"manage_dashboard_owners": 2_200,
"manage_dashboard_roles": 1_900,
- "manage_native_filters": 9_600,
+ # Includes filter-bar dividers and select-filter default values:
+ # 10,380 bytes, rounded up plus the standard 100-byte headroom.
+ "manage_native_filters": 10_500,
"open_sql_lab_with_context": 1_800,
"query_dataset": 3_700,
"remove_chart_from_dashboard": 1_300,