NousResearch/hermes-agent

MCP nullable tool schema normalization and race fixes

Cluster 16764. Data as of

Nothing open on GitHub. Fix merged in #16978 on 28 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #16978. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #16978. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #16978 fix(mcp): normalize nullable tool schemas + avoid list_changed RPC races

    merged to main

    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

    Show the diff
    • agent/anthropic_adapter.py
      @@ -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:
      @@ -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
      @@ -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
      @@ -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
      @@ -63,6 +63,7 @@
           "[email protected]": "devorun",
           "[redacted:email]": "beesrsj2500",
           "[redacted:email]": "beesrsj2500",
      +    "[redacted:email]": "ma-pony",
           "[redacted:email]": "Sonoyunchu",
           "[redacted:email]": "say8hi",
           "[email protected]": "web3blind",
    • tests/agent/test_anthropic_adapter.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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):
      @@ -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."""
      @@ -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``.
       
      @@ -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():
      @@ -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
      @@ -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")
      @@ -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 = []
      @@ -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 = ""
      @@ -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 = {}
      @@ -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 []
      @@ -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 = {}
      @@ -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 []):
      @@ -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.
      @@ -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):
      @@ -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
      @@ -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
      @@ -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.
       

1 PR closed without merging

From the close comments: merged via another change 1

  • #16766 fix(mcp): normalize nullable tool schemas · merged via another change → 02ae152, 1350d12, aa94883, #16978
Closed issues · 1
  • Closed#16764Normalize nullable MCP tool schemas and avoid list_changed RPC races
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#16978
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
28 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.