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,