# NousResearch/hermes-agent · MCP nullable tool schema normalization and race fixes

## Open now

- Nothing open on GitHub.

These issues and pull requests address normalizing nullable tool schemas in MCP and avoiding race conditions related to list_changed RPC calls. The members share a single focused theme around schema handling and concurrency correctness in the MCP layer.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #16978. Verify it addresses your case.
- **Recommended:** read PR #16978 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-16978`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #16764: Normalize nullable MCP tool schemas and avoid list_changed RPC races

## Prior & competing PRs

- **#16978: fix(mcp): normalize nullable tool schemas + avoid list_changed RPC races**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/anthropic_adapter.py`, `model_tools.py`, `scripts/release.py`, `tests/agent/test_anthropic_adapter.py`, `tests/run_agent/test_tool_arg_coercion.py`, `tests/tools/test_mcp_tool.py`, `tools/mcp_tool.py`, `tools/schema_sanitizer.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -1117,6 +1117,33 @@ def _sanitize_tool_id(tool_id: str) -> str:
         return sanitized or "tool_0"
     
     
    +def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
    +    """Normalize tool schemas before sending them to Anthropic.
    +
    +    Anthropic's tool schema validator rejects nullable unions such as
    +    ``anyOf: [{"type": "string"}, {"type": "null"}]`` that Pydantic/MCP
    +    commonly emits for optional fields. Tool optionality is represented by
    +    the parent ``required`` array, so we delegate to the shared
    +    ``strip_nullable_unions`` helper to collapse nullable unions to the
    +    non-null branch while preserving metadata like description/default.
    +
    +    ``keep_nullable_hint=False`` because the Anthropic validator does not
    +    recognize the OpenAPI-style ``nullable: true`` extension and strict
    +    schema-to-grammar converters may reject unknown keywords.
    +    """
    +    if not schema:
    +        return {"type": "object", "properties": {}}
    +
    +    from tools.schema_sanitizer import strip_nullable_unions
    +
    +    normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
    +    if not isinstance(normalized, dict):
    +        return {"type": "object", "properties": {}}
    +    if normalized.get("type") == "object" and not isinstance(normalized.get("properties"), dict):
    +        normalized = {**normalized, "properties": {}}
    +    return normalized
    +
    +
     def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
         """Convert OpenAI tool definitions to Anthropic format."""
         if not tools:
    ```

    ```diff
    @@ -1127,7 +1154,9 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
             result.append({
                 "name": fn.get("name", ""),
                 "description": fn.get("description", ""),
    -            "input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
    +            "input_schema": _normalize_tool_input_schema(
    +                fn.get("parameters", {"type": "object", "properties": {}})
    +            ),
             })
         return result
     
    ```
  - `model_tools.py`

    ```diff
    @@ -415,24 +415,27 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
             if not prop_schema:
                 continue
             expected = prop_schema.get("type")
    -        if not expected:
    +        if not expected and not _schema_allows_null(prop_schema):
                 continue
    -        coerced = _coerce_value(value, expected)
    +        coerced = _coerce_value(value, expected, schema=prop_schema)
             if coerced is not value:
                 args[key] = coerced
     
         return args
     
     
    -def _coerce_value(value: str, expected_type):
    +def _coerce_value(value: str, expected_type, schema: dict | None = None):
         """Attempt to coerce a string *value* to *expected_type*.
     
         Returns the original string when coercion is not applicable or fails.
         """
    +    if _schema_allows_null(schema) and value.strip().lower() == "null":
    +        return None
    +
         if isinstance(expected_type, list):
             # Union type — try each in order, return first successful coercion
             for t in expected_type:
    -            result = _coerce_value(value, t)
    +            result = _coerce_value(value, t, schema=schema)
                 if result is not value:
                     return result
             return value
    ```

    ```diff
    @@ -445,9 +448,35 @@ def _coerce_value(value: str, expected_type):
             return _coerce_json(value, list)
         if expected_type == "object":
             return _coerce_json(value, dict)
    +    if expected_type == "null" and value.strip().lower() == "null":
    +        return None
         return value
     
     
    +def _schema_allows_null(schema: dict | None) -> bool:
    +    """Return True when a JSON Schema fragment explicitly permits null."""
    +    if not isinstance(schema, dict):
    +        return False
    +
    +    schema_type = schema.get("type")
    +    if schema_type == "null":
    +        return True
    +    if isinstance(schema_type, list) and "null" in schema_type:
    +        return True
    +    if schema.get("nullable") is True:
    +        return True
    +
    +    for union_key in ("anyOf", "oneOf"):
    +        variants = schema.get(union_key)
    +        if not isinstance(variants, list):
    +            continue
    +        for variant in variants:
    +            if isinstance(variant, dict) and variant.get("type") == "null":
    +                return True
    +
    +    return False
    +
    +
     def _coerce_json(value: str, expected_python_type: type):
         """Parse *value* as JSON when the schema expects an array or object.
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -63,6 +63,7 @@
         "130918800+devorun@users.noreply.github.com": "devorun",
         "[redacted:email]": "beesrsj2500",
         "[redacted:email]": "beesrsj2500",
    +    "[redacted:email]": "ma-pony",
         "[redacted:email]": "Sonoyunchu",
         "[redacted:email]": "say8hi",
         "web3blind@users.noreply.github.com": "web3blind",
    ```
  - `tests/agent/test_anthropic_adapter.py`

    ```diff
    @@ -544,6 +544,36 @@ def test_empty_tools(self):
             assert convert_tools_to_anthropic([]) == []
             assert convert_tools_to_anthropic(None) == []
     
    +    def test_strips_nullable_union_from_input_schema(self):
    +        tools = [
    +            {
    +                "type": "function",
    +                "function": {
    +                    "name": "run",
    +                    "description": "Run command",
    +                    "parameters": {
    +                        "type": "object",
    +                        "properties": {
    +                            "command": {"type": "string"},
    +                            "timeout": {
    +                                "anyOf": [{"type": "integer"}, {"type": "null"}],
    +                                "default": None,
    +                            },
    +                        },
    +                        "required": ["command"],
    +                    },
    +                },
    +            }
    +        ]
    +
    +        result = convert_tools_to_anthropic(tools)
    +
    +        assert result[0]["input_schema"]["properties"]["timeout"] == {
    +            "type": "integer",
    +            "default": None,
    +        }
    +        assert result[0]["input_schema"]["required"] == ["command"]
    +
     
     # ---------------------------------------------------------------------------
     # Message conversion
    ```
  - `tests/run_agent/test_tool_arg_coercion.py`

    ```diff
    @@ -67,7 +67,7 @@ def test_scientific_notation(self):
         def test_inf_stays_string_for_integer_only(self):
             """Infinity should not be converted to int."""
             result = _coerce_number("inf")
    -        assert result == float("inf")
    +        assert result == "inf"
     
         def test_negative_float(self):
             assert _coerce_number("-2.5") == -2.5
    ```

    ```diff
    @@ -255,6 +255,35 @@ def test_coerces_stringified_object_arg(self):
                 result = coerce_tool_args("test_tool", args)
                 assert result["config"] == {"max": 50}
     
    +    def test_coerces_string_null_for_nullable_object_arg(self):
    +        """Models often emit literal "null" for optional MCP object args."""
    +        schema = self._mock_schema({
    +            "setting": {
    +                "type": "object",
    +                "additionalProperties": True,
    +                "nullable": True,
    +                "default": None,
    +            },
    +        })
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"setting": "null"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["setting"] is None
    +
    +    def test_coerces_string_null_for_nullable_array_arg(self):
    +        schema = self._mock_schema({
    +            "stages": {
    +                "type": "array",
    +                "items": {"type": "object"},
    +                "nullable": True,
    +                "default": None,
    +            },
    +        })
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"stages": "null"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["stages"] is None
    +
         def test_invalid_json_array_preserved_as_string(self):
             """If the string isn't valid JSON, pass it through — let the tool decide."""
             schema = self._mock_schema({"items": {"type": "array"}})
    ```
  - `tests/tools/test_mcp_tool.py`

    ```diff
    @@ -266,6 +266,58 @@ def test_object_in_array_items_gets_properties_filled(self):
     
             assert schema["properties"]["items"]["items"]["properties"] == {}
     
    +    def test_optional_nullable_field_is_collapsed_to_non_null_schema(self):
    +        """Anthropic rejects MCP/Pydantic anyOf-null optional parameter schemas."""
    +        from tools.mcp_tool import _normalize_mcp_input_schema
    +
    +        schema = _normalize_mcp_input_schema({
    +            "type": "object",
    +            "properties": {
    +                "command": {"type": "string"},
    +                "workdir": {
    +                    "anyOf": [{"type": "string"}, {"type": "null"}],
    +                    "default": None,
    +                    "description": "Optional working directory",
    +                },
    +            },
    +            "required": ["command"],
    +        })
    +
    +        assert schema["properties"]["workdir"] == {
    +            "type": "string",
    +            "nullable": True,
    +            "default": None,
    +            "description": "Optional working directory",
    +        }
    +        assert schema["required"] == ["command"]
    +
    +    def test_nested_nullable_array_items_are_collapsed(self):
    +        from tools.mcp_tool import _normalize_mcp_input_schema
    +
    +        schema = _normalize_mcp_input_schema({
    +            "type": "object",
    +            "properties": {
    +                "filters": {
    +                    "type": "array",
    +                    "items": {
    +                        "oneOf": [
    +                            {
    +                                "type": "object",
    +                                "properties": {"field": {"type": "string"}},
    +                            },
    +                            {"type": "null"},
    +                        ]
    +                    },
    +                }
    +            },
    +        })
    +
    +        assert schema["properties"]["filters"]["items"] == {
    +            "type": "object",
    +            "properties": {"field": {"type": "string"}},
    +            "nullable": True,
    +        }
    +
         def test_convert_mcp_schema_survives_missing_inputschema_attribute(self):
             """A Tool object without .inputSchema must not crash registration."""
             import types
    ```

    ```diff
    @@ -656,6 +708,106 @@ async def _test():
     
             asyncio.run(_test())
     
    +    def test_refresh_tools_deregisters_removed_tools(self):
    +        """Dynamic refresh removes stale registry entries for deleted tools."""
    +        from tools.registry import ToolRegistry
    +        from tools.mcp_tool import MCPServerTask
    +
    +        mock_registry = ToolRegistry()
    +        server = MCPServerTask("srv")
    +        server._config = {"command": "test"}
    +        server._tools = [_make_mcp_tool("old"), _make_mcp_tool("keep")]
    +        server._registered_tool_names = ["mcp_srv_old", "mcp_srv_keep"]
    +        server.session = MagicMock()
    +        server.session.list_tools = AsyncMock(
    +            return_value=SimpleNamespace(tools=[_make_mcp_tool("keep"), _make_mcp_tool("new")])
    +        )
    +
    +        with patch("tools.registry.registry", mock_registry):
    +            mock_registry.register(
    +                name="mcp_srv_old",
    +                toolset="mcp-srv",
    +                schema={"name": "mcp_srv_old", "description": "Old"},
    +                handler=lambda *_args, **_kwargs: "{}",
    +            )
    +            mock_registry.register(
    +                name="mcp_srv_keep",
    +                toolset="mcp-srv",
    +                schema={"name": "mcp_srv_keep", "description": "Keep"},
    +                handler=lambda *_args, **_kwargs: "{}",
    +            )
    +
    +            asyncio.run(server._refresh_tools())
    +
    +            names = mock_registry.get_all_tool_names()
    +            assert "mcp_srv_old" not in names
    +            assert "mcp_srv_keep" in names
    +            assert "mcp_srv_new" in names
    +            assert set(server._registered_tool_names) == {
    +                "mcp_srv_keep",
    +                "mcp_srv_new",
    +                "mcp_srv_list_resources",
    +                "mcp_srv_read_resource",
    +                "mcp_srv_list_prompts",
    +                "mcp_srv_get_prompt",
    +            }
    +
    +    def test_schedule_tools_refresh_keeps_task_until_done(self):
    +        """Background refresh tasks are strongly referenced and then discarded."""
    +        from tools.mcp_tool import MCPServerTask
    +
    +        async def _test():
    +            started = asyncio.Event()
    +            finish = asyncio.Event()
    +            server = MCPServerTask("srv")
    +
    +            async def fake_refresh(_server):
    +                started.set()
    +                await finish.wait()
    +
    +            with patch.object(MCPServerTask, "_refresh_tools", new=fake_refresh):
    +                server._schedule_tools_refresh()
    +
    +                await started.wait()
    +                assert len(server._pending_refresh_tasks) == 1
    +                task = next(iter(server._pending_refresh_tasks))
    +                assert not task.done()
    +
    +                finish.set()
    +                await task
    +                await asyncio.sleep(0)
    +                assert server._pending_refresh_tasks == set()
    +
    +        asyncio.run(_test())
    +
    +    def test_shutdown_cancels_pending_refresh_tasks(self):
    +        """shutdown() cancels in-flight background refresh tasks."""
    +        from tools.mcp_tool import MCPServerTask
    +
    +        async def _test():
    +            started = asyncio.Event()
    +            cancelled = asyncio.Event()
    +            server = MCPServerTask("srv")
    +
    +            async def fake_refresh(_server):
    +                started.set()
    +                try:
    +                    await asyncio.sleep(3600)
    +                except asyncio.CancelledError:
    +                    cancelled.set()
    +                    raise
    +
    +            with patch.object(MCPServerTask, "_refresh_tools", new=fake_refresh):
    +                server._schedule_tools_refresh()
    +                await started.wait()
    +
    +                await server.shutdown()
    +
    +            assert cancelled.is_set()
    +            assert server._pending_refresh_tasks == set()
    +
    +        asyncio.run(_test())
    +
         def test_empty_env_gets_safe_defaults(self):
             """Empty env dict gets safe default env vars (PATH, HOME, etc.)."""
             from tools.mcp_tool import MCPServerTask
    ```

    ```diff
    @@ -1910,18 +2062,47 @@ async def fake_connect(name, config):
     import math
     import time
     
    -from mcp.types import (
    -    CreateMessageResult,
    +class _CompatType:
    +    def __init__(self, **kwargs):
    +        self.__dict__.update(kwargs)
    +
    +
    +try:
    +    from mcp.types import (
    +        CreateMessageResult,
    +        ErrorData,
    +        SamplingCapability,
    +        TextContent,
    +    )
    +except ImportError:
    +    CreateMessageResult = _CompatType
    +    ErrorData = _CompatType
    +    SamplingCapability = _CompatType
    +    TextContent = _CompatType
    +
    +try:
    +    from mcp.types import CreateMessageResultWithTools
    +except ImportError:
    +    CreateMessageResultWithTools = _CompatType
    +
    +try:
    +    from mcp.types import SamplingToolsCapability
    +except ImportError:
    +    SamplingToolsCapability = _CompatType
    +
    +try:
    +    from mcp.types import ToolUseContent
    +except ImportError:
    +    ToolUseContent = _CompatType
    +
    +from tools.mcp_tool import (
         CreateMessageResultWithTools,
    -    ErrorData,
    -    SamplingCapability,
    +    SamplingHandler,
         SamplingToolsCapability,
    -    TextContent,
         ToolUseContent,
    +    _safe_numeric,
     )
     
    -from tools.mcp_tool import SamplingHandler, _safe_numeric
    -
     
     # ---------------------------------------------------------------------------
     # Helpers for sampling tests
    ```
  - `tools/mcp_tool.py`

    ```diff
    @@ -868,6 +868,7 @@ class MCPServerTask:
             "_task", "_ready", "_shutdown_event", "_reconnect_event",
             "_tools", "_error", "_config",
             "_sampling", "_registered_tool_names", "_auth_type", "_refresh_lock",
    +        "_rpc_lock", "_pending_refresh_tasks",
         )
     
         def __init__(self, name: str):
    ```

    ```diff
    @@ -890,6 +891,14 @@ def __init__(self, name: str):
             self._registered_tool_names: list[str] = []
             self._auth_type: str = ""
             self._refresh_lock = asyncio.Lock()
    +        # MCP stdio sessions are a single JSON-RPC stream. Some servers emit
    +        # list_changed notifications during startup; if the notification
    +        # handler calls list_tools while a normal tool call is in flight, the
    +        # stream can wedge and the user-visible tool call times out. Serialize
    +        # client-initiated RPCs per server. The lock is also applied to HTTP
    +        # transports for conservative per-server ordering.
    +        self._rpc_lock = asyncio.Lock()
    +        self._pending_refresh_tasks: set[asyncio.Task] = set()
     
         def _is_http(self) -> bool:
             """Check if this server uses HTTP transport."""
    ```

    ```diff
    @@ -897,6 +906,21 @@ def _is_http(self) -> bool:
     
         # ----- Dynamic tool discovery (notifications/tools/list_changed) -----
     
    +    async def _refresh_tools_task(self):
    +        """Run a dynamic tool refresh and log failures from background tasks."""
    +        try:
    +            await self._refresh_tools()
    +        except asyncio.CancelledError:
    +            raise
    +        except Exception:
    +            logger.exception("MCP server '%s': dynamic tool refresh failed", self.name)
    +
    +    def _schedule_tools_refresh(self) -> None:
    +        """Schedule a background tool refresh and keep it strongly referenced."""
    +        task = asyncio.create_task(self._refresh_tools_task())
    +        self._pending_refresh_tasks.add(task)
    +        task.add_done_callback(self._pending_refresh_tasks.discard)
    +
         def _make_message_handler(self):
             """Build a ``message_handler`` callback for ``ClientSession``.
     
    ```

    ```diff
    @@ -916,7 +940,16 @@ async def _handler(message):
                                     "MCP server '%s': received tools/list_changed notification",
                                     self.name,
                                 )
    -                            await self._refresh_tools()
    +                            # Some servers (notably mongodb-mcp-server) emit
    +                            # tools/list_changed immediately after initialize,
    +                            # while the client may already be executing another
    +                            # request. Refreshing synchronously inside the SDK
    +                            # notification handler can race with that request
    +                            # and wedge the stdio JSON-RPC stream, making all
    +                            # subsequent tool calls time out. Do the refresh in
    +                            # a separate task and let the handler return
    +                            # promptly.
    +                            self._schedule_tools_refresh()
                             case PromptListChangedNotification():
                                 logger.debug("MCP server '%s': prompts/list_changed (ignored)", self.name)
                             case ResourceListChangedNotification():
    ```

    ```diff
    @@ -942,12 +975,24 @@ async def _refresh_tools(self):
                 old_tool_names = set(self._registered_tool_names)
     
                 # 1. Fetch current tool list from server
    -            tools_result = await self.session.list_tools()
    +            async with self._rpc_lock:
    +                tools_result = await self.session.list_tools()
                 new_mcp_tools = tools_result.tools if hasattr(tools_result, "tools") else []
     
    -            # 2. Deregister old tools from the central registry
    -            for prefixed_name in self._registered_tool_names:
    -                registry.deregister(prefixed_name)
    +            # 2. Re-register with fresh tool list. Avoid nuke-and-repave for
    +            # all names: live agent turns may already have tool-call IDs
    +            # pointing at existing handler functions. Replacing entries
    +            # in-place is enough for unchanged names and avoids transient
    +            # "tool not connected" / stale-handler races during startup
    +            # notifications. Tools absent from the fresh list are no longer
    +            # callable, so remove only those stale registry entries first.
    +            stale_tool_names = old_tool_names - {
    +                f"mcp_{sanitize_mcp_name_component(self.name)}_"
    +                f"{sanitize_mcp_name_component(tool.name)}"
    +                for tool in new_mcp_tools
    +            }
    +            for tool_name in stale_tool_names:
    +                registry.deregister(tool_name)
     
                 # 3. Re-register with fresh tool list
                 self._tools = new_mcp_tools
    ```

    ```diff
    @@ -1204,7 +1249,8 @@ async def _discover_tools(self):
             """Discover tools from the connected session."""
             if self.session is None:
                 return
    -        tools_result = await self.session.list_tools()
    +        async with self._rpc_lock:
    +            tools_result = await self.session.list_tools()
             self._tools = (
                 tools_result.tools
                 if hasattr(tools_result, "tools")
    ```

    ```diff
    @@ -1363,6 +1409,11 @@ async def shutdown(self):
                         await self._task
                     except asyncio.CancelledError:
                         pass
    +        if self._pending_refresh_tasks:
    +            for task in list(self._pending_refresh_tasks):
    +                task.cancel()
    +            await asyncio.gather(*self._pending_refresh_tasks, return_exceptions=True)
    +            self._pending_refresh_tasks.clear()
             for tool_name in list(getattr(self, "_registered_tool_names", [])):
                 registry.deregister(tool_name)
             self._registered_tool_names = []
    ```

    ```diff
    @@ -1954,7 +2005,8 @@ def _handler(args: dict, **kwargs) -> str:
                 }, ensure_ascii=False)
     
             async def _call():
    -            result = await server.session.call_tool(tool_name, arguments=args)
    +            async with server._rpc_lock:
    +                result = await server.session.call_tool(tool_name, arguments=args)
                 # MCP CallToolResult has .content (list of content blocks) and .isError
                 if result.isError:
                     error_text = ""
    ```

    ```diff
    @@ -2052,7 +2104,8 @@ def _handler(args: dict, **kwargs) -> str:
                 }, ensure_ascii=False)
     
             async def _call():
    -            result = await server.session.list_resources()
    +            async with server._rpc_lock:
    +                result = await server.session.list_resources()
                 resources = []
                 for r in (result.resources if hasattr(result, "resources") else []):
                     entry = {}
    ```

    ```diff
    @@ -2115,7 +2168,8 @@ def _handler(args: dict, **kwargs) -> str:
                 return tool_error("Missing required parameter 'uri'")
     
             async def _call():
    -            result = await server.session.read_resource(uri)
    +            async with server._rpc_lock:
    +                result = await server.session.read_resource(uri)
                 # read_resource returns ReadResourceResult with .contents list
                 parts: List[str] = []
                 contents = result.contents if hasattr(result, "contents") else []
    ```

    ```diff
    @@ -2168,7 +2222,8 @@ def _handler(args: dict, **kwargs) -> str:
                 }, ensure_ascii=False)
     
             async def _call():
    -            result = await server.session.list_prompts()
    +            async with server._rpc_lock:
    +                result = await server.session.list_prompts()
                 prompts = []
                 for p in (result.prompts if hasattr(result, "prompts") else []):
                     entry = {}
    ```

    ```diff
    @@ -2237,7 +2292,8 @@ def _handler(args: dict, **kwargs) -> str:
             arguments = args.get("arguments", {})
     
             async def _call():
    -            result = await server.session.get_prompt(name, arguments=arguments)
    +            async with server._rpc_lock:
    +                result = await server.session.get_prompt(name, arguments=arguments)
                 # GetPromptResult has .messages list
                 messages = []
                 for msg in (result.messages if hasattr(result, "messages") else []):
    ```

    ```diff
    @@ -2321,6 +2377,11 @@ def _normalize_mcp_input_schema(schema: dict | None) -> dict:
         * ``required`` arrays are pruned to only names that exist in
           ``properties``; otherwise Google AI Studio / Gemini 400s with
           ``property is not defined``.  See PR #4651.
    +    * MCP/Pydantic optional fields commonly arrive as
    +      ``anyOf: [{...}, {"type": "null"}], default: null``.  Anthropic rejects
    +      nullable branches in tool input schemas, so nullable unions are collapsed
    +      to the non-null branch and optionality remains represented solely by the
    +      parent object's ``required`` list.
     
         All repairs are provider-agnostic and ideally produce a schema valid on
         OpenAI, Anthropic, Gemini, and Moonshot in one pass.
    ```

    ```diff
    @@ -2342,6 +2403,19 @@ def _rewrite_local_refs(node):
                 return [_rewrite_local_refs(item) for item in node]
             return node
     
    +    def _strip_nullable_union(node):
    +        """Collapse JSON Schema nullable unions to provider-safe non-null schemas.
    +
    +        Delegates to ``tools.schema_sanitizer.strip_nullable_unions`` so MCP
    +        ingestion, the Anthropic guard, and the global sanitizer all share one
    +        implementation. Keeps the ``nullable: true`` hint so runtime argument
    +        coercion can still map a model-emitted ``"null"`` string to Python
    +        ``None`` for this optional field.
    +        """
    +        from tools.schema_sanitizer import strip_nullable_unions
    +
    +        return strip_nullable_unions(node, keep_nullable_hint=True)
    +
         def _repair_object_shape(node):
             """Recursively repair object-shaped nodes: fill type, prune required."""
             if isinstance(node, list):
    ```

    ```diff
    @@ -2381,6 +2455,7 @@ def _repair_object_shape(node):
             return repaired
     
         normalized = _rewrite_local_refs(schema)
    +    normalized = _strip_nullable_union(normalized)
         normalized = _repair_object_shape(normalized)
     
         # Ensure top-level is a well-formed object schema
    ```
  - `tools/schema_sanitizer.py`

    ```diff
    @@ -17,6 +17,9 @@
       (malformed MCP server output, e.g. ``additionalProperties: "object"``).
     * ``"type": ["string", "null"]`` array types — many converters only accept
       single-string ``type``.
    +* ``anyOf`` / ``oneOf`` unions whose only purpose is to permit ``null`` for
    +  optional fields (common Pydantic/MCP shape). Anthropic rejects these at
    +  the top of ``input_schema``; collapse them to the non-null branch.
     * Unconstrained ``additionalProperties`` on objects with empty properties.
     
     This module walks the final tool schema tree (after MCP-level normalization
    ```

    ```diff
    @@ -75,9 +78,77 @@ def _sanitize_single_tool(tool: dict) -> dict:
                 top["type"] = "object"
             if "properties" not in top or not isinstance(top.get("properties"), dict):
                 top["properties"] = {}
    +    # Final pass: collapse nullable anyOf/oneOf unions that the recursive
    +    # sanitizer above leaves intact (it only handles the array-form
    +    # ``type: [X, "null"]``). Keep the ``nullable: true`` hint so runtime
    +    # argument coercion (``model_tools._schema_allows_null``) can still
    +    # map a model-emitted ``"null"`` string to Python ``None``.
    +    fn["parameters"] = strip_nullable_unions(fn["parameters"], keep_nullable_hint=True)
         return out
     
     
    +def strip_nullable_unions(
    +    schema: Any,
    +    *,
    +    keep_nullable_hint: bool = True,
    +) -> Any:
    +    """Collapse ``anyOf`` / ``oneOf`` nullable unions to the non-null branch.
    +
    +    MCP / Pydantic optional fields commonly arrive as::
    +
    +        {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}
    +
    +    Anthropic's tool input-schema validator rejects the null branch. Tool
    +    optionality is already represented by the parent object's ``required``
    +    array, so we collapse the union to the single non-null variant.
    +
    +    Metadata (``title``, ``description``, ``default``, ``examples``) on the
    +    outer union node is carried over to the replacement variant.
    +
    +    Args:
    +        schema: JSON-Schema fragment (dict, list, or scalar).
    +        keep_nullable_hint: If True, set ``nullable: true`` on the replacement
    +            to preserve the "this field may be None" signal for downstream
    +            consumers that care (e.g. runtime argument coercion that maps the
    +            literal string ``"null"`` to Python ``None``). Anthropic's
    +            validator accepts ``nullable: true`` but strict producers may
    +            prefer False.
    +
    +    Returns:
    +        The schema with nullable unions collapsed. Non-union nodes are
    +        returned unchanged.
    +    """
    +    if isinstance(schema, list):
    +        return [strip_nullable_unions(item, keep_nullable_hint=keep_nullable_hint) for item in schema]
    +    if not isinstance(schema, dict):
    +        return schema
    +
    +    stripped = {
    +        k: strip_nullable_unions(v, keep_nullable_hint=keep_nullable_hint)
    +        for k, v in schema.items()
    +    }
    +    for key in ("anyOf", "oneOf"):
    +        variants = stripped.get(key)
    +        if not isinstance(variants, list):
    +            continue
    +        non_null = [
    +            item for item in variants
    +            if not (isinstance(item, dict) and item.get("type") == "null")
    +        ]
    +        # Only collapse when we actually dropped a null branch AND exactly
    +        # one non-null branch survives (otherwise the union is meaningful
    +        # and we leave it alone).
    +        if len(non_null) == 1 and len(non_null) != len(variants):
    +            replacement = dict(non_null[0]) if isinstance(non_null[0], dict) else {}
    +            if keep_nullable_hint:
    +                replacement.setdefault("nullable", True)
    +            for meta_key in ("title", "description", "default", "examples"):
    +                if meta_key in stripped and meta_key not in replacement:
    +                    replacement[meta_key] = stripped[meta_key]
    +            return strip_nullable_unions(replacement, keep_nullable_hint=keep_nullable_hint)
    +    return stripped
    +
    +
     def _sanitize_node(node: Any, path: str) -> Any:
         """Recursively sanitize a JSON-Schema fragment.
     
    ```
- **#16766: fix(mcp): normalize nullable tool schemas**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `02ae15222`, `1350d12b0`, `aa9488328`, `#16978`

## Fix status

- Merged to main in PR #16978 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-28`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
