tien238lnd commented on code in PR #44573:
URL: https://github.com/apache/superset/pull/44573#discussion_r4101460755


##########
tests/unit_tests/mcp_service/test_mcp_tool_registration.py:
##########
@@ -249,6 +250,96 @@ 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_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]
+            pytest.fail(
+                f"{tool.name}: cannot resolve request schema reference 
{reference!r}"
+            )
+    if "properties" in request:
+        return request
+    pytest.fail(
+        f"{tool.name}: unrecognised request schema shape {sorted(request)}; "
+        "the null-default check below would silently pass"
+    )

Review Comment:
   Taken in f147b2a44, as `raise AssertionError` in both places in the helper 
and in the missing-tool guard, so the narrowing holds for `tool` as well. mypy 
over the two touched files reports nothing in them here.



##########
superset/mcp_service/chart/schemas.py:
##########
@@ -745,7 +746,7 @@ def check_unknown_fields(cls, data: Any) -> Any:
         return _check_unknown_fields(data, cls)
 
 
-class BaseChartConfig(UnknownFieldCheckMixin):
+class BaseChartConfig(UnknownFieldCheckMixin, OmittedMeansUnchanged):

Review Comment:
   Confirmed and fixed in f147b2a44. `TableColumnConfig` takes the base, and 
with it a stored `{"Region": {"columnWidth": 200, "d3NumberFormat": ",.2f"}}` 
survives an update that names only `d3TimeFormat`.
   
   Following the measurements you and @gabotorresruiz posted, `MetricCurrency`, 
`ColumnRef`, `AxisConfig` and `FilterConfig` take it too. Only 
`TableColumnConfig` clobbered, but those four are the rest of the advertised 
nulls, and the deeper check below only reaches zero with them included.



##########
tests/unit_tests/mcp_service/test_mcp_tool_registration.py:
##########
@@ -249,6 +250,96 @@ 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_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]
+            pytest.fail(
+                f"{tool.name}: cannot resolve request schema reference 
{reference!r}"
+            )
+    if "properties" in request:
+        return request
+    pytest.fail(
+        f"{tool.name}: unrecognised request schema shape {sorted(request)}; "
+        "the null-default check below would silently pass"
+    )
+
+
+def _null_defaults(schema: dict[str, Any]) -> list[str]:
+    """Return the fields of one schema that advertise null as their default."""
+    return sorted(
+        field
+        for field, spec in schema.get("properties", {}).items()

Review Comment:
   That was the real gap, thank you. `_null_defaults` now walks the whole 
schema — `properties` at every depth, through `anyOf`/`oneOf`/`items`/`$defs` — 
so it covers a tool's inlined parameters and `model_json_schema()` alike. That 
is what surfaced `TableColumnConfig`; with the five nested models on the base, 
both tests are green at zero, and reverting any one of the five reds them.



##########
superset/mcp_service/utils/schema_utils.py:
##########
@@ -27,13 +27,38 @@
 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():

Review Comment:
   Agreed, and thanks for checking it — the loop deletes a key from each inner 
field dict, never from `properties`. Left as is.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to