This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch mcp-select-filter-default-value
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1c9eafe2386fa5d62232913bc5d051e11cad2120
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Mon Oct 5 13:30:42 2026 +0000

    feat(mcp): allow default values on filter_select native filters
    
    Adds default_value to the MCP manage_native_filters create and update
    specs for filter_select filters, building the stored defaultDataMask
    with the same builder apply_dashboard_filters already uses for applied
    values. default_to_first_item and an explicit default_value are
    mutually exclusive, matching the dashboard UI.
---
 superset/mcp_service/dashboard/schemas.py          |  46 ++-
 .../dashboard/tool/apply_dashboard_filters.py      |  91 +-----
 .../dashboard/tool/manage_native_filters.py        | 101 +++++-
 .../dashboard/tool/test_manage_native_filters.py   | 359 +++++++++++++++++++++
 4 files changed, 503 insertions(+), 94 deletions(-)

diff --git a/superset/mcp_service/dashboard/schemas.py 
b/superset/mcp_service/dashboard/schemas.py
index 37e1c255e1e..07a4bb591e0 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -2268,6 +2268,10 @@ class DeleteDashboardResponse(BaseModel):
 # manage_native_filters schemas
 # ---------------------------------------------------------------------------
 
+# The JSON scalars a filter_select selection can hold. Mirrors the value array
+# the frontend stores in a native filter's ``filterState.value``.
+FilterSelectValue = bool | int | float | str | None
+
 
 class BaseNewFilterSpec(BaseModel):
     """Common fields shared by all new native filter specs."""
@@ -2313,6 +2317,24 @@ class FilterSelectSpec(BaseNewFilterSpec):
     search_all_options: bool = Field(
         False, description="Query the database on search rather than 
client-side"
     )
+    default_value: List[FilterSelectValue] | None = Field(
+        None,
+        description=(
+            "Default selected value(s) for the filter, applied when a "
+            "viewer opens the dashboard without having changed it. Omit for "
+            "no default (an empty selection). Mutually exclusive with "
+            "default_to_first_item."
+        ),
+    )
+
+    @model_validator(mode="after")
+    def _validate_default_value_compat(self) -> "FilterSelectSpec":
+        if self.default_to_first_item and self.default_value is not None:
+            raise ValueError(
+                "default_to_first_item and default_value are mutually "
+                "exclusive; set at most one."
+            )
+        return self
 
 
 class FilterTimeSpec(BaseNewFilterSpec):
@@ -2374,6 +2396,17 @@ class NativeFilterUpdateSpec(BaseModel):
     search_all_options: bool | None = Field(
         None, description="Search all options in the database (filter_select 
only)"
     )
+    default_value: List[FilterSelectValue] | None = Field(
+        None,
+        description=(
+            "New default selected value(s) for the filter (filter_select "
+            "only). Pass an empty list to clear the default to no "
+            "selection. Mutually exclusive with setting "
+            "default_to_first_item=True in the same update; if the filter "
+            "currently has default_to_first_item enabled, also pass "
+            "default_to_first_item=False in this same update."
+        ),
+    )
     default_time_range: str | None = Field(
         None, description="Default time range (filter_time only)"
     )
@@ -2390,6 +2423,15 @@ class NativeFilterUpdateSpec(BaseModel):
     def _validate_default_time_range(cls, v: str | None) -> str | None:
         return validate_time_range(v)
 
+    @model_validator(mode="after")
+    def _validate_default_value_compat(self) -> "NativeFilterUpdateSpec":
+        if self.default_to_first_item and self.default_value is not None:
+            raise ValueError(
+                "default_to_first_item and default_value are mutually "
+                "exclusive; set at most one in the same update."
+            )
+        return self
+
 
 class ManageNativeFiltersRequest(BaseModel):
     """Request schema for the manage_native_filters tool."""
@@ -2479,10 +2521,6 @@ class ManageNativeFiltersResponse(BaseModel):
 # apply_dashboard_filters schemas
 # ---------------------------------------------------------------------------
 
-# The JSON scalars a filter_select selection can hold. Mirrors the value array
-# the frontend stores in a native filter's ``filterState.value``.
-FilterSelectValue = bool | int | float | str | None
-
 
 class ApplyFilterValueSpec(BaseModel):
     """A value to apply to one existing native filter.
diff --git a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py 
b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
index 64a53414afd..755b40e71f1 100644
--- a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
+++ b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
@@ -32,7 +32,7 @@ from fastmcp import Context
 from flask import current_app
 from superset_core.mcp.decorators import tool, ToolAnnotations
 
-from superset.constants import EMPTY_FILTER_SQL_EXPRESSION, NO_TIME_RANGE
+from superset.constants import NO_TIME_RANGE
 from superset.extensions import event_logger
 from superset.mcp_service.dashboard.permalink import (
     build_dashboard_permalink_url,
@@ -44,9 +44,10 @@ from superset.mcp_service.dashboard.schemas import (
     ApplyDashboardFiltersRequest,
     ApplyDashboardFiltersResponse,
     ApplyFilterValueSpec,
-    FilterSelectValue,
 )
 from superset.mcp_service.dashboard.tool.manage_native_filters import (
+    _FilterValidationError as _FilterApplyError,
+    _select_data_mask,
     current_native_filter_config,
 )
 
@@ -56,16 +57,6 @@ logger = logging.getLogger(__name__)
 # types manage_native_filters can create.
 SUPPORTED_FILTER_TYPES: frozenset[str] = frozenset({"filter_select", 
"filter_time"})
 
-# Display strings the frontend uses when labelling a selected value; mirrored
-# here so a permalink's label reads the same as a UI-applied one.
-_NULL_LABEL = "<NULL>"
-_TRUE_LABEL = "TRUE"
-_FALSE_LABEL = "FALSE"
-
-
-class _FilterApplyError(Exception):
-    """Raised internally when a requested filter value cannot be applied."""
-
 
 def _publish_filters_applied(dashboard_id: int, permalink_key: str) -> bool:
     """Best-effort principal nudge; filter state stays in the authorized 
permalink."""
@@ -142,82 +133,6 @@ def _resolve_filter(reference: str, configs: 
list[dict[str, Any]]) -> dict[str,
     )
 
 
-def _value_label(value: FilterSelectValue) -> str:
-    """Format one selected value the way the dashboard UI labels it."""
-    if value is None:
-        return _NULL_LABEL
-    if isinstance(value, bool):
-        return _TRUE_LABEL if value else _FALSE_LABEL
-    return str(value)
-
-
-def _select_data_mask(
-    conf: dict[str, Any], values: list[FilterSelectValue]
-) -> dict[str, Any]:
-    """Build the data mask a filter_select filter produces for ``values``.
-
-    Mirrors the frontend's ``getSelectExtraFormData``: a non-empty selection
-    becomes an ``IN`` predicate on the filter's target column, and an empty
-    selection on a filter marked ``enableEmptyFilter`` becomes an impossible
-    predicate (the "required filter, nothing chosen" state) rather than no
-    filtering at all.
-    """
-    targets = [target for target in (conf.get("targets") or []) if target]
-    column = (targets[0].get("column") or {}).get("name") if targets else None
-    if not column:
-        raise _FilterApplyError(
-            f"Filter '{conf.get('name') or conf.get('id')}' has no target "
-            "column, so a value cannot be applied to it."
-        )
-
-    control_values = conf.get("controlValues") or {}
-    if control_values.get("inverseSelection"):
-        raise _FilterApplyError(
-            f"Filter '{conf.get('name') or conf.get('id')}' enables inverse "
-            "selection, which this tool does not support."
-        )
-    if (operator := control_values.get("operatorType", "exact")) != "exact":
-        raise _FilterApplyError(
-            f"Filter '{conf.get('name') or conf.get('id')}' uses matching "
-            f"operator '{operator}', which this tool does not support. "
-            "Only exact-match select filters are supported."
-        )
-    # A single-select filter renders one value; storing several would disagree
-    # with the control the moment a viewer touches it. multiSelect defaults to
-    # true, so only an explicit false restricts the selection.
-    if control_values.get("multiSelect") is False and len(values) > 1:
-        raise _FilterApplyError(
-            f"Filter '{conf.get('name') or conf.get('id')}' is single-select "
-            f"and accepts at most one value, but {len(values)} were given."
-        )
-
-    if values:
-        extra_form_data: dict[str, Any] = {
-            "filters": [{"col": column, "op": "IN", "val": list(values)}]
-        }
-        filter_state: dict[str, Any] = {
-            "value": list(values),
-            "label": ", ".join(_value_label(value) for value in values),
-        }
-    else:
-        extra_form_data = (
-            {
-                "adhoc_filters": [
-                    {
-                        "expressionType": "SQL",
-                        "clause": "WHERE",
-                        "sqlExpression": EMPTY_FILTER_SQL_EXPRESSION,
-                    }
-                ]
-            }
-            if control_values.get("enableEmptyFilter")
-            else {}
-        )
-        filter_state = {"value": None}
-
-    return {"extraFormData": extra_form_data, "filterState": filter_state}
-
-
 def _time_data_mask(conf: dict[str, Any], time_range: str) -> dict[str, Any]:
     """Build the data mask a filter_time filter produces for ``time_range``."""
     is_set = bool(time_range) and time_range != NO_TIME_RANGE
diff --git a/superset/mcp_service/dashboard/tool/manage_native_filters.py 
b/superset/mcp_service/dashboard/tool/manage_native_filters.py
index 09386e9374a..b30f119246c 100644
--- a/superset/mcp_service/dashboard/tool/manage_native_filters.py
+++ b/superset/mcp_service/dashboard/tool/manage_native_filters.py
@@ -30,10 +30,12 @@ from typing import Any
 from fastmcp import Context
 from superset_core.mcp.decorators import tool, ToolAnnotations
 
+from superset.constants import EMPTY_FILTER_SQL_EXPRESSION
 from superset.extensions import event_logger
 from superset.mcp_service.dashboard.constants import generate_id
 from superset.mcp_service.dashboard.schemas import (
     FilterSelectSpec,
+    FilterSelectValue,
     FilterTimeSpec,
     ManageNativeFiltersRequest,
     ManageNativeFiltersResponse,
@@ -54,6 +56,12 @@ _SELECT_CONTROL_FIELDS: dict[str, str] = {
     "search_all_options": "searchAllOptions",
 }
 
+# Display strings the frontend uses when labelling a selected value; mirrored
+# here so a stored default's label reads the same as an applied one.
+_NULL_LABEL = "<NULL>"
+_TRUE_LABEL = "TRUE"
+_FALSE_LABEL = "FALSE"
+
 
 class _FilterValidationError(Exception):
     """Raised internally when a filter operation fails validation."""
@@ -64,6 +72,83 @@ def _empty_data_mask() -> dict[str, Any]:
     return {"filterState": {"value": None}, "extraFormData": {}}
 
 
+def _value_label(value: FilterSelectValue) -> str:
+    """Format one selected value the way the dashboard UI labels it."""
+    if value is None:
+        return _NULL_LABEL
+    if isinstance(value, bool):
+        return _TRUE_LABEL if value else _FALSE_LABEL
+    return str(value)
+
+
+def _select_data_mask(
+    conf: dict[str, Any], values: list[FilterSelectValue]
+) -> dict[str, Any]:
+    """Build the data mask a filter_select filter produces for ``values``.
+
+    Mirrors the frontend's ``getSelectExtraFormData``: a non-empty selection
+    becomes an ``IN`` predicate on the filter's target column, and an empty
+    selection on a filter marked ``enableEmptyFilter`` becomes an impossible
+    predicate (the "required filter, nothing chosen" state) rather than no
+    filtering at all. Shared by ``apply_dashboard_filters`` (applied values)
+    and this module (default values on create/update) so both paths agree.
+    """
+    targets = [target for target in (conf.get("targets") or []) if target]
+    column = (targets[0].get("column") or {}).get("name") if targets else None
+    if not column:
+        raise _FilterValidationError(
+            f"Filter '{conf.get('name') or conf.get('id')}' has no target "
+            "column, so a value cannot be applied to it."
+        )
+
+    control_values = conf.get("controlValues") or {}
+    if control_values.get("inverseSelection"):
+        raise _FilterValidationError(
+            f"Filter '{conf.get('name') or conf.get('id')}' enables inverse "
+            "selection, which this tool does not support."
+        )
+    if (operator := control_values.get("operatorType", "exact")) != "exact":
+        raise _FilterValidationError(
+            f"Filter '{conf.get('name') or conf.get('id')}' uses matching "
+            f"operator '{operator}', which this tool does not support. "
+            "Only exact-match select filters are supported."
+        )
+    # A single-select filter renders one value; storing several would disagree
+    # with the control the moment a viewer touches it. multiSelect defaults to
+    # true, so only an explicit false restricts the selection.
+    if control_values.get("multiSelect") is False and len(values) > 1:
+        raise _FilterValidationError(
+            f"Filter '{conf.get('name') or conf.get('id')}' is single-select "
+            f"and accepts at most one value, but {len(values)} were given."
+        )
+
+    if values:
+        extra_form_data: dict[str, Any] = {
+            "filters": [{"col": column, "op": "IN", "val": list(values)}]
+        }
+        filter_state: dict[str, Any] = {
+            "value": list(values),
+            "label": ", ".join(_value_label(value) for value in values),
+        }
+    else:
+        extra_form_data = (
+            {
+                "adhoc_filters": [
+                    {
+                        "expressionType": "SQL",
+                        "clause": "WHERE",
+                        "sqlExpression": EMPTY_FILTER_SQL_EXPRESSION,
+                    }
+                ]
+            }
+            if control_values.get("enableEmptyFilter")
+            else {}
+        )
+        filter_state = {"value": None}
+
+    return {"extraFormData": extra_form_data, "filterState": filter_state}
+
+
 def _time_data_mask(default_time_range: str | None) -> dict[str, Any]:
     """Build the default data mask for a time filter.
 
@@ -137,7 +222,7 @@ def _build_new_filter_config(
         }
         if spec.sort_ascending is not None:
             control_values["sortAscending"] = spec.sort_ascending
-        return {
+        config: dict[str, Any] = {
             "id": filter_id,
             "type": "NATIVE_FILTER",
             "filterType": "filter_select",
@@ -151,6 +236,9 @@ def _build_new_filter_config(
             "defaultDataMask": _empty_data_mask(),
             "cascadeParentIds": [],
         }
+        if spec.default_value is not None:
+            config["defaultDataMask"] = _select_data_mask(config, 
spec.default_value)
+        return config
 
     # filter_time: no dataset target, empty controlValues
     return {
@@ -173,7 +261,7 @@ def _validate_update_type_compat(
     """Reject update fields that do not apply to the filter's type."""
     select_fields_set = [
         field
-        for field in (*_SELECT_CONTROL_FIELDS, "dataset_id", "column")
+        for field in (*_SELECT_CONTROL_FIELDS, "dataset_id", "column", 
"default_value")
         if getattr(spec, field) is not None
     ]
     if filter_type != "filter_select" and select_fields_set:
@@ -240,6 +328,15 @@ def _merge_filter_update(
             control_values[control_key] = value
     merged["controlValues"] = control_values
 
+    if spec.default_value is not None:
+        if control_values.get("defaultToFirstItem"):
+            raise _FilterValidationError(
+                f"Filter '{spec.id}' has default_to_first_item enabled; "
+                "pass default_to_first_item=False in this same update "
+                "before setting an explicit default_value."
+            )
+        merged["defaultDataMask"] = _select_data_mask(merged, 
spec.default_value)
+
     if spec.default_time_range is not None:
         merged["defaultDataMask"] = _time_data_mask(spec.default_time_range)
 
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 075d7d65467..71a97c7ffe0 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
@@ -37,6 +37,7 @@ Covers:
 - Permission denied (DashboardForbiddenError)
 """
 
+import copy
 import logging
 from collections.abc import Callable, Iterator
 from typing import Any
@@ -298,6 +299,144 @@ async def test_add_filter_time(mcp_server):
     }
 
 
[email protected]
+async def test_add_filter_select_with_default_value(mcp_server):
+    """default_value on create builds defaultDataMask via the same
+    builder apply_dashboard_filters uses for applied values (SC-121506)."""
+    captured: dict = {"current_config": []}
+    dashboard = _mock_dashboard(filters=[])
+
+    with (
+        patch(DAO_FIND_BY_ID, return_value=dashboard),
+        patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
+        patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+    ):
+        data = await _call(
+            mcp_server,
+            {
+                "dashboard_id": 1,
+                "add": [
+                    {
+                        "filter_type": "filter_select",
+                        "name": "Region",
+                        "dataset_id": 5,
+                        "column": "region",
+                        "default_value": ["EMEA"],
+                    }
+                ],
+            },
+        )
+
+    assert data["error"] is None
+    config = captured["payload"]["modified"][0]
+    assert config["defaultDataMask"] == {
+        "extraFormData": {"filters": [{"col": "region", "op": "IN", "val": 
["EMEA"]}]},
+        "filterState": {"value": ["EMEA"], "label": "EMEA"},
+    }
+
+
[email protected]
+async def test_add_filter_select_default_value_empty_list(mcp_server):
+    """An explicit empty default_value is a no-selection default, distinct
+    from omitting the field entirely but producing the same empty mask when
+    enable_empty_filter is not set."""
+    captured: dict = {"current_config": []}
+    dashboard = _mock_dashboard(filters=[])
+
+    with (
+        patch(DAO_FIND_BY_ID, return_value=dashboard),
+        patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
+        patch(COMMAND_PATH, side_effect=_mock_command(captured)),
+    ):
+        data = await _call(
+            mcp_server,
+            {
+                "dashboard_id": 1,
+                "add": [
+                    {
+                        "filter_type": "filter_select",
+                        "name": "Region",
+                        "dataset_id": 5,
+                        "column": "region",
+                        "default_value": [],
+                    }
+                ],
+            },
+        )
+
+    assert data["error"] is None
+    config = captured["payload"]["modified"][0]
+    assert config["defaultDataMask"] == {
+        "extraFormData": {},
+        "filterState": {"value": None},
+    }
+
+
[email protected]
+async def test_add_filter_select_default_value_single_select_overflow_rejected(
+    mcp_server,
+):
+    """multi_select=False with multiple default values is rejected the same
+    way apply_dashboard_filters rejects multiple applied values."""
+    dashboard = _mock_dashboard(filters=[])
+
+    with (
+        patch(DAO_FIND_BY_ID, return_value=dashboard),
+        patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
+    ):
+        data = await _call(
+            mcp_server,
+            {
+                "dashboard_id": 1,
+                "add": [
+                    {
+                        "filter_type": "filter_select",
+                        "name": "Region",
+                        "dataset_id": 5,
+                        "column": "region",
+                        "multi_select": False,
+                        "default_value": ["EMEA", "APAC"],
+                    }
+                ],
+            },
+        )
+
+    assert "single-select" in data["error"]
+    assert "2 were given" in data["error"]
+
+
[email protected]
+async def 
test_add_filter_select_default_to_first_item_and_default_value_rejected(
+    mcp_server,
+):
+    """Request-schema validation happens at the MCP tool-call boundary,
+    before the tool body runs, so the client raises ToolError rather than
+    returning a JSON error body (mirrors
+    test_add_filter_time_rejects_unparseable_default)."""
+    from fastmcp.exceptions import ToolError
+
+    dashboard = _mock_dashboard(filters=[])
+
+    with patch(DAO_FIND_BY_ID, return_value=dashboard):
+        with pytest.raises(ToolError, match="mutually exclusive"):
+            await _call(
+                mcp_server,
+                {
+                    "dashboard_id": 1,
+                    "add": [
+                        {
+                            "filter_type": "filter_select",
+                            "name": "Region",
+                            "dataset_id": 5,
+                            "column": "region",
+                            "default_to_first_item": True,
+                            "default_value": ["EMEA"],
+                        }
+                    ],
+                },
+            )
+
+
 # ---------------------------------------------------------------------------
 # Update
 # ---------------------------------------------------------------------------
@@ -386,6 +525,144 @@ async def 
test_update_time_field_on_select_filter_rejected(mcp_server):
     assert "filter_time" in data["error"]
 
 
[email protected]
+async def test_update_sets_default_value(mcp_server):
+    """default_value on update builds defaultDataMask via the same builder
+    used on create and by apply_dashboard_filters (SC-121506)."""
+    captured: dict = {"current_config": [EXISTING_SELECT_FILTER]}
+    dashboard = _mock_dashboard(filters=[EXISTING_SELECT_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-existing1",
+                        "default_value": ["APAC"],
+                    }
+                ],
+            },
+        )
+
+    assert data["error"] is None
+    config = captured["payload"]["modified"][0]
+    assert config["defaultDataMask"] == {
+        "extraFormData": {"filters": [{"col": "region", "op": "IN", "val": 
["APAC"]}]},
+        "filterState": {"value": ["APAC"], "label": "APAC"},
+    }
+
+
[email protected]
+async def test_update_default_value_empty_list_clears_default(mcp_server):
+    existing = copy.deepcopy(EXISTING_SELECT_FILTER)
+    existing["defaultDataMask"] = {
+        "extraFormData": {"filters": [{"col": "region", "op": "IN", "val": 
["EMEA"]}]},
+        "filterState": {"value": ["EMEA"], "label": "EMEA"},
+    }
+    captured: dict = {"current_config": [existing]}
+    dashboard = _mock_dashboard(filters=[existing])
+
+    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-existing1", "default_value": 
[]}],
+            },
+        )
+
+    assert data["error"] is None
+    config = captured["payload"]["modified"][0]
+    assert config["defaultDataMask"] == {
+        "extraFormData": {},
+        "filterState": {"value": None},
+    }
+
+
[email protected]
+async def test_update_default_value_on_time_filter_rejected(mcp_server):
+    dashboard = _mock_dashboard(filters=[EXISTING_TIME_FILTER])
+
+    with patch(DAO_FIND_BY_ID, return_value=dashboard):
+        data = await _call(
+            mcp_server,
+            {
+                "dashboard_id": 1,
+                "update": [
+                    {
+                        "id": "NATIVE_FILTER-existing2",
+                        "default_value": ["EMEA"],
+                    }
+                ],
+            },
+        )
+
+    assert "default_value" in data["error"]
+    assert "filter_select filters" in data["error"]
+
+
[email protected]
+async def 
test_update_default_value_with_existing_default_to_first_item_rejected(
+    mcp_server,
+):
+    """An update that sets default_value must also explicitly clear a
+    pre-existing default_to_first_item; the two cannot both be effectively
+    enabled after the merge (SC-121506)."""
+    existing = copy.deepcopy(EXISTING_SELECT_FILTER)
+    existing["controlValues"]["defaultToFirstItem"] = True
+    dashboard = _mock_dashboard(filters=[existing])
+
+    with patch(DAO_FIND_BY_ID, return_value=dashboard):
+        data = await _call(
+            mcp_server,
+            {
+                "dashboard_id": 1,
+                "update": [
+                    {
+                        "id": "NATIVE_FILTER-existing1",
+                        "default_value": ["EMEA"],
+                    }
+                ],
+            },
+        )
+
+    assert "default_to_first_item" in data["error"]
+
+
[email protected]
+async def 
test_update_default_to_first_item_and_default_value_rejected(mcp_server):
+    """Request-schema validation happens at the MCP tool-call boundary,
+    before the tool body runs, so the client raises ToolError rather than
+    returning a JSON error body."""
+    from fastmcp.exceptions import ToolError
+
+    dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER])
+
+    with patch(DAO_FIND_BY_ID, return_value=dashboard):
+        with pytest.raises(ToolError, match="mutually exclusive"):
+            await _call(
+                mcp_server,
+                {
+                    "dashboard_id": 1,
+                    "update": [
+                        {
+                            "id": "NATIVE_FILTER-existing1",
+                            "default_to_first_item": True,
+                            "default_value": ["EMEA"],
+                        }
+                    ],
+                },
+            )
+
+
 @pytest.mark.asyncio
 async def test_update_duplicate_filter_ids_rejected(mcp_server):
     dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER])
@@ -891,6 +1168,88 @@ class TestNativeFilterUpdateSpecTimeRangeValidation:
             )
 
 
+# ---------------------------------------------------------------------------
+# default_value validation (SC-121506)
+# ---------------------------------------------------------------------------
+
+
+class TestFilterSelectSpecDefaultValueValidation:
+    """FilterSelectSpec.default_value and default_to_first_item are
+    mutually exclusive, matching the Superset UI."""
+
+    def test_default_value_alone_passes(self) -> None:
+        from superset.mcp_service.dashboard.schemas import FilterSelectSpec
+
+        spec = FilterSelectSpec.model_validate(
+            {
+                "filter_type": "filter_select",
+                "name": "Region",
+                "dataset_id": 5,
+                "column": "region",
+                "default_value": ["EMEA"],
+            }
+        )
+        assert spec.default_value == ["EMEA"]
+
+    def test_default_to_first_item_alone_passes(self) -> None:
+        from superset.mcp_service.dashboard.schemas import FilterSelectSpec
+
+        spec = FilterSelectSpec.model_validate(
+            {
+                "filter_type": "filter_select",
+                "name": "Region",
+                "dataset_id": 5,
+                "column": "region",
+                "default_to_first_item": True,
+            }
+        )
+        assert spec.default_to_first_item is True
+        assert spec.default_value is None
+
+    def test_both_set_raises(self) -> None:
+        from pydantic import ValidationError
+
+        from superset.mcp_service.dashboard.schemas import FilterSelectSpec
+
+        with pytest.raises(ValidationError, match="mutually exclusive"):
+            FilterSelectSpec.model_validate(
+                {
+                    "filter_type": "filter_select",
+                    "name": "Region",
+                    "dataset_id": 5,
+                    "column": "region",
+                    "default_to_first_item": True,
+                    "default_value": ["EMEA"],
+                }
+            )
+
+
+class TestNativeFilterUpdateSpecDefaultValueValidation:
+    """NativeFilterUpdateSpec gets the same mutual-exclusion guard."""
+
+    def test_default_value_alone_passes(self) -> None:
+        from superset.mcp_service.dashboard.schemas import 
NativeFilterUpdateSpec
+
+        spec = NativeFilterUpdateSpec.model_validate(
+            {"id": "NATIVE_FILTER-1", "default_value": ["EMEA"]}
+        )
+        assert spec.default_value == ["EMEA"]
+
+    def test_both_set_raises(self) -> None:
+        from pydantic import ValidationError
+
+        from superset.mcp_service.dashboard.schemas import 
NativeFilterUpdateSpec
+
+        with pytest.raises(ValidationError, match="mutually exclusive"):
+            NativeFilterUpdateSpec.model_validate(
+                {
+                    "id": "NATIVE_FILTER-1",
+                    "default_to_first_item": True,
+                    "default_value": ["EMEA"],
+                }
+            )
+
+
 @pytest.mark.asyncio
 async def test_add_filter_time_rejects_unparseable_default(mcp_server):
     """End-to-end: manage_native_filters must not persist a dead time

Reply via email to