This is an automated email from the ASF dual-hosted git repository.
rusackas 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 10356c30edb fix(mcp): stop partial-update tools from advertising null
defaults (#44573)
10356c30edb is described below
commit 10356c30edb5bdfbe80199b3a99bb9a616a15130
Author: Nguyen Dang Trung Tien <[email protected]>
AuthorDate: Sat Oct 3 12:16:12 2026 +0700
fix(mcp): stop partial-update tools from advertising null defaults (#44573)
Co-authored-by: Claude Opus 5 <[email protected]>
---
superset/mcp_service/chart/schemas.py | 15 +--
superset/mcp_service/dashboard/schemas.py | 3 +-
superset/mcp_service/dataset/schemas.py | 7 +-
superset/mcp_service/utils/schema_utils.py | 27 +++++-
.../mcp_service/test_mcp_tool_registration.py | 106 +++++++++++++++++++++
5 files changed, 147 insertions(+), 11 deletions(-)
diff --git a/superset/mcp_service/chart/schemas.py
b/superset/mcp_service/chart/schemas.py
index 9b14c9ab8c2..c411b481912 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -78,6 +78,7 @@ from superset.mcp_service.utils.sanitization import (
sanitize_user_input,
sanitize_user_input_with_changes,
)
+from superset.mcp_service.utils.schema_utils import OmittedMeansUnchanged
from superset.mcp_service.utils.serialization import (
JsonSafeMapping,
JsonSafeRows,
@@ -799,7 +800,7 @@ class UnknownFieldCheckMixin(BaseModel):
return _check_unknown_fields(data, cls)
-class BaseChartConfig(UnknownFieldCheckMixin):
+class BaseChartConfig(UnknownFieldCheckMixin, OmittedMeansUnchanged):
"""Fields shared by every MCP chart configuration."""
temporal_column: str | None = Field(
@@ -826,7 +827,7 @@ class BaseChartConfig(UnknownFieldCheckMixin):
)
-class ColumnRef(UnknownFieldCheckMixin):
+class ColumnRef(UnknownFieldCheckMixin, OmittedMeansUnchanged):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
name: str | None = Field(
@@ -952,7 +953,7 @@ class ColumnRef(UnknownFieldCheckMixin):
)
-class AxisConfig(UnknownFieldCheckMixin):
+class AxisConfig(UnknownFieldCheckMixin, OmittedMeansUnchanged):
model_config = ConfigDict(extra="ignore")
title: str | None = Field(None, max_length=200)
@@ -990,7 +991,7 @@ class CurrencyFormat(UnknownFieldCheckMixin):
LEGEND_POSITION_LITERAL = Literal["top", "bottom", "left", "right"]
-class FilterConfig(UnknownFieldCheckMixin):
+class FilterConfig(UnknownFieldCheckMixin, OmittedMeansUnchanged):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
column: str = Field(
@@ -2359,7 +2360,7 @@ class BigNumberChartConfig(BaseChartConfig):
return self
-class TableColumnConfig(UnknownFieldCheckMixin):
+class TableColumnConfig(UnknownFieldCheckMixin, OmittedMeansUnchanged):
"""Display formatting supported by the MCP table-chart schema."""
model_config = ConfigDict(
@@ -4043,7 +4044,9 @@ class
GenerateExploreLinkRequest(ChartRequestNormalizerMixin, FormDataCacheContr
)
-class UpdateChartRequest(ChartRequestNormalizerMixin, QueryCacheControl):
+class UpdateChartRequest(
+ ChartRequestNormalizerMixin, OmittedMeansUnchanged, QueryCacheControl
+):
model_config = ConfigDict(populate_by_name=True)
identifier: int | str = Field(
diff --git a/superset/mcp_service/dashboard/schemas.py
b/superset/mcp_service/dashboard/schemas.py
index 6b5d0d419a0..e6d25586db9 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -122,6 +122,7 @@ from superset.mcp_service.utils.sanitization import (
sanitize_user_input,
sanitize_user_input_with_changes,
)
+from superset.mcp_service.utils.schema_utils import OmittedMeansUnchanged
from superset.mcp_service.utils.serialization import JsonSafeRows,
OptionalRowCount
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils.core import DatasourceType
@@ -826,7 +827,7 @@ class GenerateDashboardRequest(BaseModel):
)
-class UpdateDashboardRequest(BaseModel):
+class UpdateDashboardRequest(OmittedMeansUnchanged):
"""Request schema for updating an existing dashboard's layout/theme/style.
All fields are optional; only the fields explicitly passed are applied.
diff --git a/superset/mcp_service/dataset/schemas.py
b/superset/mcp_service/dataset/schemas.py
index b0a9c999dea..12ec0bdcdb0 100644
--- a/superset/mcp_service/dataset/schemas.py
+++ b/superset/mcp_service/dataset/schemas.py
@@ -60,6 +60,7 @@ from superset.mcp_service.system.schemas import (
TagInfo,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
+from superset.mcp_service.utils.schema_utils import OmittedMeansUnchanged
from superset.mcp_service.utils.serialization import (
JsonSafeRows,
OptionalRowCount,
@@ -635,7 +636,7 @@ UPDATABLE_METRIC_FIELDS: frozenset[str] = frozenset(
)
-class MetricCurrency(BaseModel):
+class MetricCurrency(OmittedMeansUnchanged):
"""Currency formatting configuration for a metric."""
symbol: str | None = Field(
@@ -648,7 +649,7 @@ class MetricCurrency(BaseModel):
)
-class DatasetMetricProperties(BaseModel):
+class DatasetMetricProperties(OmittedMeansUnchanged):
"""Dataset identifier and writable saved-metric properties."""
model_config = ConfigDict(populate_by_name=True)
@@ -946,7 +947,7 @@ UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
)
-class UpdateDatasetRequest(BaseModel):
+class UpdateDatasetRequest(OmittedMeansUnchanged):
"""Request schema for update_dataset."""
model_config = ConfigDict(populate_by_name=True)
diff --git a/superset/mcp_service/utils/schema_utils.py
b/superset/mcp_service/utils/schema_utils.py
index 31f2e9c9e3c..e7c5955d632 100644
--- a/superset/mcp_service/utils/schema_utils.py
+++ b/superset/mcp_service/utils/schema_utils.py
@@ -27,13 +27,38 @@ from __future__ import annotations
import logging
from typing import Any, Callable, List, Type, TypeVar
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel, GetJsonSchemaHandler, ValidationError
logger = logging.getLogger(__name__)
T = TypeVar("T")
+class OmittedMeansUnchanged(BaseModel):
+ """Base for models that tell an omitted field from an explicit ``null``.
+
+ These models read ``model_fields_set``, so leaving a field out is not the
+ same as passing ``null``. Pydantic advertises ``"default": null`` for every
+ optional field, and a client that materialises those defaults then sends
+ nulls the caller never named, which the model reads as deliberate input.
+
+ Dropping the advertised default keeps the fields optional without handing
+ clients a value to fill in. Nothing else changes: an omitted field is still
+ unset, and an explicit ``null`` still means whatever the tool already made
+ it mean.
+ """
+
+ @classmethod
+ def __get_pydantic_json_schema__(
+ cls, core_schema: Any, handler: GetJsonSchemaHandler
+ ) -> dict[str, Any]:
+ schema = handler(core_schema)
+ for field in schema.get("properties", {}).values():
+ if "default" in field and field["default"] is None:
+ del field["default"]
+ return schema
+
+
class JSONParseError(ValueError):
"""Raised when JSON parsing fails with helpful context."""
diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
index 516e8a04220..42856765b08 100644
--- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
+++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
@@ -26,6 +26,7 @@ import pytest
from superset.mcp_service.app import get_default_instructions,
init_fastmcp_server, mcp
from superset.mcp_service.utils.response_size_utils import
COMMITTED_WRITE_SPECS
+from superset.mcp_service.utils.schema_utils import OmittedMeansUnchanged
# Patch target for the feature_flag_manager imported inside
_apply_config_guards
_FFM_PATH = "superset.extensions.feature_flag_manager"
@@ -263,6 +264,111 @@ def _run(coro):
return asyncio.run(coro)
+# Tools whose request model tells "field omitted" from "field set to null":
+# omitting leaves the stored value alone, an explicit null is deliberate input.
+# Their optional fields must not advertise a default, or a client that
+# materialises defaults sends nulls for everything the caller never named.
+OMITTED_MEANS_UNCHANGED_TOOLS = (
+ "update_chart",
+ "update_dashboard",
+ "update_dataset",
+ "update_dataset_metric",
+)
+
+
+def _request_model_schema(tool: Any) -> dict[str, Any]:
+ """Return the JSON Schema of a tool's ``request`` argument."""
+ schema = tool.parameters or {}
+ request = schema.get("properties", {}).get("request", {})
+ for candidate in (request, *request.get("allOf", [])):
+ if reference := candidate.get("$ref"):
+ name = reference.rpartition("/")[2]
+ for container in ("$defs", "definitions"):
+ if name in schema.get(container, {}):
+ return schema[container][name]
+ raise AssertionError(
+ f"{tool.name}: cannot resolve request schema reference
{reference!r}"
+ )
+ if "properties" in request:
+ return request
+ raise AssertionError(
+ f"{tool.name}: unrecognised request schema shape {sorted(request)}; "
+ "the null-default check below would silently pass"
+ )
+
+
+def _null_defaults(schema: Any, path: str = "") -> list[str]:
+ """Return every field at or under ``schema`` that defaults to null.
+
+ The walk goes all the way down on purpose. A tool's advertised parameters
+ arrive fully inlined, while ``model_json_schema()`` puts nested models in
+ ``$defs``, and a null default is a hazard wherever it sits: the fields of
+ a nested model are merged into stored state the same way.
+ """
+ hits: list[str] = []
+ if isinstance(schema, dict):
+ for name, spec in (schema.get("properties") or {}).items():
+ if isinstance(spec, dict) and "default" in spec and
spec["default"] is None:
+ hits.append(f"{path}/{name}")
+ for key, value in schema.items():
+ hits.extend(_null_defaults(value, f"{path}/{key}"))
+ elif isinstance(schema, list):
+ for index, value in enumerate(schema):
+ hits.extend(_null_defaults(value, f"{path}/{index}"))
+ return sorted(hits)
+
+
+def _omitted_means_unchanged_models() -> list[type[OmittedMeansUnchanged]]:
+ """Return every model built on ``OmittedMeansUnchanged``."""
+ models: list[type[OmittedMeansUnchanged]] = []
+ pending: list[type[OmittedMeansUnchanged]] = [OmittedMeansUnchanged]
+ while pending:
+ for subclass in pending.pop().__subclasses__():
+ if subclass not in models:
+ models.append(subclass)
+ pending.append(subclass)
+ return models
+
+
+def test_partial_update_tools_advertise_no_null_default() -> None:
+ """No optional field of a partial-update tool offers null as its
default."""
+ registered = {tool.name: tool for tool in _run(mcp.list_tools())}
+ advertised = {}
+ for name in OMITTED_MEANS_UNCHANGED_TOOLS:
+ tool: Any = registered.get(name)
+ if tool is None:
+ raise AssertionError(
+ f"{name} is not registered, so its schema cannot be checked"
+ )
+ if offenders := _null_defaults(_request_model_schema(tool)):
+ advertised[name] = offenders
+
+ assert not advertised, (
+ "Partial-update tools must not advertise null defaults, or a client "
+ f"filling them clears values the caller never named: {advertised}"
+ )
+
+
+def test_omitted_means_unchanged_models_advertise_no_null_default() -> None:
+ """Every model on the base keeps null out of its advertised defaults.
+
+ ``update_chart`` reads ``model_fields_set`` on the nested chart config
+ rather than on the request, so the config models carry the base too. This
+ covers them, and any model that joins them later.
+ """
+ models = _omitted_means_unchanged_models()
+ assert models, "OmittedMeansUnchanged has no subclasses; is the import
stale?"
+
+ offenders = {
+ model.__name__: null_defaults
+ for model in models
+ if (null_defaults := _null_defaults(model.model_json_schema()))
+ }
+ assert not offenders, (
+ f"Models on OmittedMeansUnchanged must not advertise null defaults:
{offenders}"
+ )
+
+
def test_mcp_app_imports_successfully():
"""Test that the MCP app can be imported without errors."""
assert mcp is not None