{"repo":"NousResearch/hermes-agent","public_id":"16764","display_label":"MCP nullable tool schema normalization and race fixes","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16978,"merged_at":"2026-04-28T11:58:03+00:00"},"resolution":{"state":"resolved","by":"pr-16978"},"freshness":{"state":"stale","last_verified_at":"2026-04-28T11:58:03+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16764,"pr_number":16978,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-28T11:58:03+00:00"}],"members":[{"kind":"issue","number":16764,"title":"Normalize nullable MCP tool schemas and avoid list_changed RPC races","url":null,"state":"closed"},{"kind":"pr","number":16766,"title":"fix(mcp): normalize nullable tool schemas","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"02ae15222","number":0},{"kind":"commit","value":"1350d12b0","number":0},{"kind":"commit","value":"aa9488328","number":0},{"kind":"pr","value":"#16978","number":16978}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16978,"title":"fix(mcp): normalize nullable tool schemas + avoid list_changed RPC races","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1117,6 +1117,33 @@ def _sanitize_tool_id(tool_id: str) -> str:\n     return sanitized or \"tool_0\"\n \n \n+def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:\n+    \"\"\"Normalize tool schemas before sending them to Anthropic.\n+\n+    Anthropic's tool schema validator rejects nullable unions such as\n+    ``anyOf: [{\"type\": \"string\"}, {\"type\": \"null\"}]`` that Pydantic/MCP\n+    commonly emits for optional fields. Tool optionality is represented by\n+    the parent ``required`` array, so we delegate to the shared\n+    ``strip_nullable_unions`` helper to collapse nullable unions to the\n+    non-null branch while preserving metadata like description/default.\n+\n+    ``keep_nullable_hint=False`` because the Anthropic validator does not\n+    recognize the OpenAPI-style ``nullable: true`` extension and strict\n+    schema-to-grammar converters may reject unknown keywords.\n+    \"\"\"\n+    if not schema:\n+        return {\"type\": \"object\", \"properties\": {}}\n+\n+    from tools.schema_sanitizer import strip_nullable_unions\n+\n+    normalized = strip_nullable_unions(schema, keep_nullable_hint=False)\n+    if not isinstance(normalized, dict):\n+        return {\"type\": \"object\", \"properties\": {}}\n+    if normalized.get(\"type\") == \"object\" and not isinstance(normalized.get(\"properties\"), dict):\n+        normalized = {**normalized, \"properties\": {}}\n+    return normalized\n+\n+\n def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:\n     \"\"\"Convert OpenAI tool definitions to Anthropic format.\"\"\"\n     if not tools:","@@ -1127,7 +1154,9 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:\n         result.append({\n             \"name\": fn.get(\"name\", \"\"),\n             \"description\": fn.get(\"description\", \"\"),\n-            \"input_schema\": fn.get(\"parameters\", {\"type\": \"object\", \"properties\": {}}),\n+            \"input_schema\": _normalize_tool_input_schema(\n+                fn.get(\"parameters\", {\"type\": \"object\", \"properties\": {}})\n+            ),\n         })\n     return result\n "]},{"file":"model_tools.py","hunks":["@@ -415,24 +415,27 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:\n         if not prop_schema:\n             continue\n         expected = prop_schema.get(\"type\")\n-        if not expected:\n+        if not expected and not _schema_allows_null(prop_schema):\n             continue\n-        coerced = _coerce_value(value, expected)\n+        coerced = _coerce_value(value, expected, schema=prop_schema)\n         if coerced is not value:\n             args[key] = coerced\n \n     return args\n \n \n-def _coerce_value(value: str, expected_type):\n+def _coerce_value(value: str, expected_type, schema: dict | None = None):\n     \"\"\"Attempt to coerce a string *value* to *expected_type*.\n \n     Returns the original string when coercion is not applicable or fails.\n     \"\"\"\n+    if _schema_allows_null(schema) and value.strip().lower() == \"null\":\n+        return None\n+\n     if isinstance(expected_type, list):\n         # Union type — try each in order, return first successful coercion\n         for t in expected_type:\n-            result = _coerce_value(value, t)\n+            result = _coerce_value(value, t, schema=schema)\n             if result is not value:\n                 return result\n         return value","@@ -445,9 +448,35 @@ def _coerce_value(value: str, expected_type):\n         return _coerce_json(value, list)\n     if expected_type == \"object\":\n         return _coerce_json(value, dict)\n+    if expected_type == \"null\" and value.strip().lower() == \"null\":\n+        return None\n     return value\n \n \n+def _schema_allows_null(schema: dict | None) -> bool:\n+    \"\"\"Return True when a JSON Schema fragment explicitly permits null.\"\"\"\n+    if not isinstance(schema, dict):\n+        return False\n+\n+    schema_type = schema.get(\"type\")\n+    if schema_type == \"null\":\n+        return True\n+    if isinstance(schema_type, list) and \"null\" in schema_type:\n+        return True\n+    if schema.get(\"nullable\") is True:\n+        return True\n+\n+    for union_key in (\"anyOf\", \"oneOf\"):\n+        variants = schema.get(union_key)\n+        if not isinstance(variants, list):\n+            continue\n+        for variant in variants:\n+            if isinstance(variant, dict) and variant.get(\"type\") == \"null\":\n+                return True\n+\n+    return False\n+\n+\n def _coerce_json(value: str, expected_python_type: type):\n     \"\"\"Parse *value* as JSON when the schema expects an array or object.\n "]},{"file":"scripts/release.py","hunks":["@@ -63,6 +63,7 @@\n     \"130918800+devorun@users.noreply.github.com\": \"devorun\",\n     \"[redacted:email]\": \"beesrsj2500\",\n     \"[redacted:email]\": \"beesrsj2500\",\n+    \"[redacted:email]\": \"ma-pony\",\n     \"[redacted:email]\": \"Sonoyunchu\",\n     \"[redacted:email]\": \"say8hi\",\n     \"web3blind@users.noreply.github.com\": \"web3blind\","]},{"file":"tests/agent/test_anthropic_adapter.py","hunks":["@@ -544,6 +544,36 @@ def test_empty_tools(self):\n         assert convert_tools_to_anthropic([]) == []\n         assert convert_tools_to_anthropic(None) == []\n \n+    def test_strips_nullable_union_from_input_schema(self):\n+        tools = [\n+            {\n+                \"type\": \"function\",\n+                \"function\": {\n+                    \"name\": \"run\",\n+                    \"description\": \"Run command\",\n+                    \"parameters\": {\n+                        \"type\": \"object\",\n+                        \"properties\": {\n+                            \"command\": {\"type\": \"string\"},\n+                            \"timeout\": {\n+                                \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n+                                \"default\": None,\n+                            },\n+                        },\n+                        \"required\": [\"command\"],\n+                    },\n+                },\n+            }\n+        ]\n+\n+        result = convert_tools_to_anthropic(tools)\n+\n+        assert result[0][\"input_schema\"][\"properties\"][\"timeout\"] == {\n+            \"type\": \"integer\",\n+            \"default\": None,\n+        }\n+        assert result[0][\"input_schema\"][\"required\"] == [\"command\"]\n+\n \n # ---------------------------------------------------------------------------\n # Message conversion"]},{"file":"tests/run_agent/test_tool_arg_coercion.py","hunks":["@@ -67,7 +67,7 @@ def test_scientific_notation(self):\n     def test_inf_stays_string_for_integer_only(self):\n         \"\"\"Infinity should not be converted to int.\"\"\"\n         result = _coerce_number(\"inf\")\n-        assert result == float(\"inf\")\n+        assert result == \"inf\"\n \n     def test_negative_float(self):\n         assert _coerce_number(\"-2.5\") == -2.5","@@ -255,6 +255,35 @@ def test_coerces_stringified_object_arg(self):\n             result = coerce_tool_args(\"test_tool\", args)\n             assert result[\"config\"] == {\"max\": 50}\n \n+    def test_coerces_string_null_for_nullable_object_arg(self):\n+        \"\"\"Models often emit literal \"null\" for optional MCP object args.\"\"\"\n+        schema = self._mock_schema({\n+            \"setting\": {\n+                \"type\": \"object\",\n+                \"additionalProperties\": True,\n+                \"nullable\": True,\n+                \"default\": None,\n+            },\n+        })\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"setting\": \"null\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"setting\"] is None\n+\n+    def test_coerces_string_null_for_nullable_array_arg(self):\n+        schema = self._mock_schema({\n+            \"stages\": {\n+                \"type\": \"array\",\n+                \"items\": {\"type\": \"object\"},\n+                \"nullable\": True,\n+                \"default\": None,\n+            },\n+        })\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"stages\": \"null\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"stages\"] is None\n+\n     def test_invalid_json_array_preserved_as_string(self):\n         \"\"\"If the string isn't valid JSON, pass it through — let the tool decide.\"\"\"\n         schema = self._mock_schema({\"items\": {\"type\": \"array\"}})"]},{"file":"tests/tools/test_mcp_tool.py","hunks":["@@ -266,6 +266,58 @@ def test_object_in_array_items_gets_properties_filled(self):\n \n         assert schema[\"properties\"][\"items\"][\"items\"][\"properties\"] == {}\n \n+    def test_optional_nullable_field_is_collapsed_to_non_null_schema(self):\n+        \"\"\"Anthropic rejects MCP/Pydantic anyOf-null optional parameter schemas.\"\"\"\n+        from tools.mcp_tool import _normalize_mcp_input_schema\n+\n+        schema = _normalize_mcp_input_schema({\n+            \"type\": \"object\",\n+            \"properties\": {\n+                \"command\": {\"type\": \"string\"},\n+                \"workdir\": {\n+                    \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n+                    \"default\": None,\n+                    \"description\": \"Optional working directory\",\n+                },\n+            },\n+            \"required\": [\"command\"],\n+        })\n+\n+        assert schema[\"properties\"][\"workdir\"] == {\n+            \"type\": \"string\",\n+            \"nullable\": True,\n+            \"default\": None,\n+            \"description\": \"Optional working directory\",\n+        }\n+        assert schema[\"required\"] == [\"command\"]\n+\n+    def test_nested_nullable_array_items_are_collapsed(self):\n+        from tools.mcp_tool import _normalize_mcp_input_schema\n+\n+        schema = _normalize_mcp_input_schema({\n+            \"type\": \"object\",\n+            \"properties\": {\n+                \"filters\": {\n+                    \"type\": \"array\",\n+                    \"items\": {\n+                        \"oneOf\": [\n+                            {\n+                                \"type\": \"object\",\n+                                \"properties\": {\"field\": {\"type\": \"string\"}},\n+                            },\n+                            {\"type\": \"null\"},\n+                        ]\n+                    },\n+                }\n+            },\n+        })\n+\n+        assert schema[\"properties\"][\"filters\"][\"items\"] == {\n+            \"type\": \"object\",\n+            \"properties\": {\"field\": {\"type\": \"string\"}},\n+            \"nullable\": True,\n+        }\n+\n     def test_convert_mcp_schema_survives_missing_inputschema_attribute(self):\n         \"\"\"A Tool object without .inputSchema must not crash registration.\"\"\"\n         import types","@@ -656,6 +708,106 @@ async def _test():\n \n         asyncio.run(_test())\n \n+    def test_refresh_tools_deregisters_removed_tools(self):\n+        \"\"\"Dynamic refresh removes stale registry entries for deleted tools.\"\"\"\n+        from tools.registry import ToolRegistry\n+        from tools.mcp_tool import MCPServerTask\n+\n+        mock_registry = ToolRegistry()\n+        server = MCPServerTask(\"srv\")\n+        server._config = {\"command\": \"test\"}\n+        server._tools = [_make_mcp_tool(\"old\"), _make_mcp_tool(\"keep\")]\n+        server._registered_tool_names = [\"mcp_srv_old\", \"mcp_srv_keep\"]\n+        server.session = MagicMock()\n+        server.session.list_tools = AsyncMock(\n+            return_value=SimpleNamespace(tools=[_make_mcp_tool(\"keep\"), _make_mcp_tool(\"new\")])\n+        )\n+\n+        with patch(\"tools.registry.registry\", mock_registry):\n+            mock_registry.register(\n+                name=\"mcp_srv_old\",\n+                toolset=\"mcp-srv\",\n+                schema={\"name\": \"mcp_srv_old\", \"description\": \"Old\"},\n+                handler=lambda *_args, **_kwargs: \"{}\",\n+            )\n+            mock_registry.register(\n+                name=\"mcp_srv_keep\",\n+                toolset=\"mcp-srv\",\n+                schema={\"name\": \"mcp_srv_keep\", \"description\": \"Keep\"},\n+                handler=lambda *_args, **_kwargs: \"{}\",\n+            )\n+\n+            asyncio.run(server._refresh_tools())\n+\n+            names = mock_registry.get_all_tool_names()\n+            assert \"mcp_srv_old\" not in names\n+            assert \"mcp_srv_keep\" in names\n+            assert \"mcp_srv_new\" in names\n+            assert set(server._registered_tool_names) == {\n+                \"mcp_srv_keep\",\n+                \"mcp_srv_new\",\n+                \"mcp_srv_list_resources\",\n+                \"mcp_srv_read_resource\",\n+                \"mcp_srv_list_prompts\",\n+                \"mcp_srv_get_prompt\",\n+            }\n+\n+    def test_schedule_tools_refresh_keeps_task_until_done(self):\n+        \"\"\"Background refresh tasks are strongly referenced and then discarded.\"\"\"\n+        from tools.mcp_tool import MCPServerTask\n+\n+        async def _test():\n+            started = asyncio.Event()\n+            finish = asyncio.Event()\n+            server = MCPServerTask(\"srv\")\n+\n+            async def fake_refresh(_server):\n+                started.set()\n+                await finish.wait()\n+\n+            with patch.object(MCPServerTask, \"_refresh_tools\", new=fake_refresh):\n+                server._schedule_tools_refresh()\n+\n+                await started.wait()\n+                assert len(server._pending_refresh_tasks) == 1\n+                task = next(iter(server._pending_refresh_tasks))\n+                assert not task.done()\n+\n+                finish.set()\n+                await task\n+                await asyncio.sleep(0)\n+                assert server._pending_refresh_tasks == set()\n+\n+        asyncio.run(_test())\n+\n+    def test_shutdown_cancels_pending_refresh_tasks(self):\n+        \"\"\"shutdown() cancels in-flight background refresh tasks.\"\"\"\n+        from tools.mcp_tool import MCPServerTask\n+\n+        async def _test():\n+            started = asyncio.Event()\n+            cancelled = asyncio.Event()\n+            server = MCPServerTask(\"srv\")\n+\n+            async def fake_refresh(_server):\n+                started.set()\n+                try:\n+                    await asyncio.sleep(3600)\n+                except asyncio.CancelledError:\n+                    cancelled.set()\n+                    raise\n+\n+            with patch.object(MCPServerTask, \"_refresh_tools\", new=fake_refresh):\n+                server._schedule_tools_refresh()\n+                await started.wait()\n+\n+                await server.shutdown()\n+\n+            assert cancelled.is_set()\n+            assert server._pending_refresh_tasks == set()\n+\n+        asyncio.run(_test())\n+\n     def test_empty_env_gets_safe_defaults(self):\n         \"\"\"Empty env dict gets safe default env vars (PATH, HOME, etc.).\"\"\"\n         from tools.mcp_tool import MCPServerTask","@@ -1910,18 +2062,47 @@ async def fake_connect(name, config):\n import math\n import time\n \n-from mcp.types import (\n-    CreateMessageResult,\n+class _CompatType:\n+    def __init__(self, **kwargs):\n+        self.__dict__.update(kwargs)\n+\n+\n+try:\n+    from mcp.types import (\n+        CreateMessageResult,\n+        ErrorData,\n+        SamplingCapability,\n+        TextContent,\n+    )\n+except ImportError:\n+    CreateMessageResult = _CompatType\n+    ErrorData = _CompatType\n+    SamplingCapability = _CompatType\n+    TextContent = _CompatType\n+\n+try:\n+    from mcp.types import CreateMessageResultWithTools\n+except ImportError:\n+    CreateMessageResultWithTools = _CompatType\n+\n+try:\n+    from mcp.types import SamplingToolsCapability\n+except ImportError:\n+    SamplingToolsCapability = _CompatType\n+\n+try:\n+    from mcp.types import ToolUseContent\n+except ImportError:\n+    ToolUseContent = _CompatType\n+\n+from tools.mcp_tool import (\n     CreateMessageResultWithTools,\n-    ErrorData,\n-    SamplingCapability,\n+    SamplingHandler,\n     SamplingToolsCapability,\n-    TextContent,\n     ToolUseContent,\n+    _safe_numeric,\n )\n \n-from tools.mcp_tool import SamplingHandler, _safe_numeric\n-\n \n # ---------------------------------------------------------------------------\n # Helpers for sampling tests"]},{"file":"tools/mcp_tool.py","hunks":["@@ -868,6 +868,7 @@ class MCPServerTask:\n         \"_task\", \"_ready\", \"_shutdown_event\", \"_reconnect_event\",\n         \"_tools\", \"_error\", \"_config\",\n         \"_sampling\", \"_registered_tool_names\", \"_auth_type\", \"_refresh_lock\",\n+        \"_rpc_lock\", \"_pending_refresh_tasks\",\n     )\n \n     def __init__(self, name: str):","@@ -890,6 +891,14 @@ def __init__(self, name: str):\n         self._registered_tool_names: list[str] = []\n         self._auth_type: str = \"\"\n         self._refresh_lock = asyncio.Lock()\n+        # MCP stdio sessions are a single JSON-RPC stream. Some servers emit\n+        # list_changed notifications during startup; if the notification\n+        # handler calls list_tools while a normal tool call is in flight, the\n+        # stream can wedge and the user-visible tool call times out. Serialize\n+        # client-initiated RPCs per server. The lock is also applied to HTTP\n+        # transports for conservative per-server ordering.\n+        self._rpc_lock = asyncio.Lock()\n+        self._pending_refresh_tasks: set[asyncio.Task] = set()\n \n     def _is_http(self) -> bool:\n         \"\"\"Check if this server uses HTTP transport.\"\"\"","@@ -897,6 +906,21 @@ def _is_http(self) -> bool:\n \n     # ----- Dynamic tool discovery (notifications/tools/list_changed) -----\n \n+    async def _refresh_tools_task(self):\n+        \"\"\"Run a dynamic tool refresh and log failures from background tasks.\"\"\"\n+        try:\n+            await self._refresh_tools()\n+        except asyncio.CancelledError:\n+            raise\n+        except Exception:\n+            logger.exception(\"MCP server '%s': dynamic tool refresh failed\", self.name)\n+\n+    def _schedule_tools_refresh(self) -> None:\n+        \"\"\"Schedule a background tool refresh and keep it strongly referenced.\"\"\"\n+        task = asyncio.create_task(self._refresh_tools_task())\n+        self._pending_refresh_tasks.add(task)\n+        task.add_done_callback(self._pending_refresh_tasks.discard)\n+\n     def _make_message_handler(self):\n         \"\"\"Build a ``message_handler`` callback for ``ClientSession``.\n ","@@ -916,7 +940,16 @@ async def _handler(message):\n                                 \"MCP server '%s': received tools/list_changed notification\",\n                                 self.name,\n                             )\n-                            await self._refresh_tools()\n+                            # Some servers (notably mongodb-mcp-server) emit\n+                            # tools/list_changed immediately after initialize,\n+                            # while the client may already be executing another\n+                            # request. Refreshing synchronously inside the SDK\n+                            # notification handler can race with that request\n+                            # and wedge the stdio JSON-RPC stream, making all\n+                            # subsequent tool calls time out. Do the refresh in\n+                            # a separate task and let the handler return\n+                            # promptly.\n+                            self._schedule_tools_refresh()\n                         case PromptListChangedNotification():\n                             logger.debug(\"MCP server '%s': prompts/list_changed (ignored)\", self.name)\n                         case ResourceListChangedNotification():","@@ -942,12 +975,24 @@ async def _refresh_tools(self):\n             old_tool_names = set(self._registered_tool_names)\n \n             # 1. Fetch current tool list from server\n-            tools_result = await self.session.list_tools()\n+            async with self._rpc_lock:\n+                tools_result = await self.session.list_tools()\n             new_mcp_tools = tools_result.tools if hasattr(tools_result, \"tools\") else []\n \n-            # 2. Deregister old tools from the central registry\n-            for prefixed_name in self._registered_tool_names:\n-                registry.deregister(prefixed_name)\n+            # 2. Re-register with fresh tool list. Avoid nuke-and-repave for\n+            # all names: live agent turns may already have tool-call IDs\n+            # pointing at existing handler functions. Replacing entries\n+            # in-place is enough for unchanged names and avoids transient\n+            # \"tool not connected\" / stale-handler races during startup\n+            # notifications. Tools absent from the fresh list are no longer\n+            # callable, so remove only those stale registry entries first.\n+            stale_tool_names = old_tool_names - {\n+                f\"mcp_{sanitize_mcp_name_component(self.name)}_\"\n+                f\"{sanitize_mcp_name_component(tool.name)}\"\n+                for tool in new_mcp_tools\n+            }\n+            for tool_name in stale_tool_names:\n+                registry.deregister(tool_name)\n \n             # 3. Re-register with fresh tool list\n             self._tools = new_mcp_tools","@@ -1204,7 +1249,8 @@ async def _discover_tools(self):\n         \"\"\"Discover tools from the connected session.\"\"\"\n         if self.session is None:\n             return\n-        tools_result = await self.session.list_tools()\n+        async with self._rpc_lock:\n+            tools_result = await self.session.list_tools()\n         self._tools = (\n             tools_result.tools\n             if hasattr(tools_result, \"tools\")","@@ -1363,6 +1409,11 @@ async def shutdown(self):\n                     await self._task\n                 except asyncio.CancelledError:\n                     pass\n+        if self._pending_refresh_tasks:\n+            for task in list(self._pending_refresh_tasks):\n+                task.cancel()\n+            await asyncio.gather(*self._pending_refresh_tasks, return_exceptions=True)\n+            self._pending_refresh_tasks.clear()\n         for tool_name in list(getattr(self, \"_registered_tool_names\", [])):\n             registry.deregister(tool_name)\n         self._registered_tool_names = []","@@ -1954,7 +2005,8 @@ def _handler(args: dict, **kwargs) -> str:\n             }, ensure_ascii=False)\n \n         async def _call():\n-            result = await server.session.call_tool(tool_name, arguments=args)\n+            async with server._rpc_lock:\n+                result = await server.session.call_tool(tool_name, arguments=args)\n             # MCP CallToolResult has .content (list of content blocks) and .isError\n             if result.isError:\n                 error_text = \"\"","@@ -2052,7 +2104,8 @@ def _handler(args: dict, **kwargs) -> str:\n             }, ensure_ascii=False)\n \n         async def _call():\n-            result = await server.session.list_resources()\n+            async with server._rpc_lock:\n+                result = await server.session.list_resources()\n             resources = []\n             for r in (result.resources if hasattr(result, \"resources\") else []):\n                 entry = {}","@@ -2115,7 +2168,8 @@ def _handler(args: dict, **kwargs) -> str:\n             return tool_error(\"Missing required parameter 'uri'\")\n \n         async def _call():\n-            result = await server.session.read_resource(uri)\n+            async with server._rpc_lock:\n+                result = await server.session.read_resource(uri)\n             # read_resource returns ReadResourceResult with .contents list\n             parts: List[str] = []\n             contents = result.contents if hasattr(result, \"contents\") else []","@@ -2168,7 +2222,8 @@ def _handler(args: dict, **kwargs) -> str:\n             }, ensure_ascii=False)\n \n         async def _call():\n-            result = await server.session.list_prompts()\n+            async with server._rpc_lock:\n+                result = await server.session.list_prompts()\n             prompts = []\n             for p in (result.prompts if hasattr(result, \"prompts\") else []):\n                 entry = {}","@@ -2237,7 +2292,8 @@ def _handler(args: dict, **kwargs) -> str:\n         arguments = args.get(\"arguments\", {})\n \n         async def _call():\n-            result = await server.session.get_prompt(name, arguments=arguments)\n+            async with server._rpc_lock:\n+                result = await server.session.get_prompt(name, arguments=arguments)\n             # GetPromptResult has .messages list\n             messages = []\n             for msg in (result.messages if hasattr(result, \"messages\") else []):","@@ -2321,6 +2377,11 @@ def _normalize_mcp_input_schema(schema: dict | None) -> dict:\n     * ``required`` arrays are pruned to only names that exist in\n       ``properties``; otherwise Google AI Studio / Gemini 400s with\n       ``property is not defined``.  See PR #4651.\n+    * MCP/Pydantic optional fields commonly arrive as\n+      ``anyOf: [{...}, {\"type\": \"null\"}], default: null``.  Anthropic rejects\n+      nullable branches in tool input schemas, so nullable unions are collapsed\n+      to the non-null branch and optionality remains represented solely by the\n+      parent object's ``required`` list.\n \n     All repairs are provider-agnostic and ideally produce a schema valid on\n     OpenAI, Anthropic, Gemini, and Moonshot in one pass.","@@ -2342,6 +2403,19 @@ def _rewrite_local_refs(node):\n             return [_rewrite_local_refs(item) for item in node]\n         return node\n \n+    def _strip_nullable_union(node):\n+        \"\"\"Collapse JSON Schema nullable unions to provider-safe non-null schemas.\n+\n+        Delegates to ``tools.schema_sanitizer.strip_nullable_unions`` so MCP\n+        ingestion, the Anthropic guard, and the global sanitizer all share one\n+        implementation. Keeps the ``nullable: true`` hint so runtime argument\n+        coercion can still map a model-emitted ``\"null\"`` string to Python\n+        ``None`` for this optional field.\n+        \"\"\"\n+        from tools.schema_sanitizer import strip_nullable_unions\n+\n+        return strip_nullable_unions(node, keep_nullable_hint=True)\n+\n     def _repair_object_shape(node):\n         \"\"\"Recursively repair object-shaped nodes: fill type, prune required.\"\"\"\n         if isinstance(node, list):","@@ -2381,6 +2455,7 @@ def _repair_object_shape(node):\n         return repaired\n \n     normalized = _rewrite_local_refs(schema)\n+    normalized = _strip_nullable_union(normalized)\n     normalized = _repair_object_shape(normalized)\n \n     # Ensure top-level is a well-formed object schema"]},{"file":"tools/schema_sanitizer.py","hunks":["@@ -17,6 +17,9 @@\n   (malformed MCP server output, e.g. ``additionalProperties: \"object\"``).\n * ``\"type\": [\"string\", \"null\"]`` array types — many converters only accept\n   single-string ``type``.\n+* ``anyOf`` / ``oneOf`` unions whose only purpose is to permit ``null`` for\n+  optional fields (common Pydantic/MCP shape). Anthropic rejects these at\n+  the top of ``input_schema``; collapse them to the non-null branch.\n * Unconstrained ``additionalProperties`` on objects with empty properties.\n \n This module walks the final tool schema tree (after MCP-level normalization","@@ -75,9 +78,77 @@ def _sanitize_single_tool(tool: dict) -> dict:\n             top[\"type\"] = \"object\"\n         if \"properties\" not in top or not isinstance(top.get(\"properties\"), dict):\n             top[\"properties\"] = {}\n+    # Final pass: collapse nullable anyOf/oneOf unions that the recursive\n+    # sanitizer above leaves intact (it only handles the array-form\n+    # ``type: [X, \"null\"]``). Keep the ``nullable: true`` hint so runtime\n+    # argument coercion (``model_tools._schema_allows_null``) can still\n+    # map a model-emitted ``\"null\"`` string to Python ``None``.\n+    fn[\"parameters\"] = strip_nullable_unions(fn[\"parameters\"], keep_nullable_hint=True)\n     return out\n \n \n+def strip_nullable_unions(\n+    schema: Any,\n+    *,\n+    keep_nullable_hint: bool = True,\n+) -> Any:\n+    \"\"\"Collapse ``anyOf`` / ``oneOf`` nullable unions to the non-null branch.\n+\n+    MCP / Pydantic optional fields commonly arrive as::\n+\n+        {\"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}], \"default\": null}\n+\n+    Anthropic's tool input-schema validator rejects the null branch. Tool\n+    optionality is already represented by the parent object's ``required``\n+    array, so we collapse the union to the single non-null variant.\n+\n+    Metadata (``title``, ``description``, ``default``, ``examples``) on the\n+    outer union node is carried over to the replacement variant.\n+\n+    Args:\n+        schema: JSON-Schema fragment (dict, list, or scalar).\n+        keep_nullable_hint: If True, set ``nullable: true`` on the replacement\n+            to preserve the \"this field may be None\" signal for downstream\n+            consumers that care (e.g. runtime argument coercion that maps the\n+            literal string ``\"null\"`` to Python ``None``). Anthropic's\n+            validator accepts ``nullable: true`` but strict producers may\n+            prefer False.\n+\n+    Returns:\n+        The schema with nullable unions collapsed. Non-union nodes are\n+        returned unchanged.\n+    \"\"\"\n+    if isinstance(schema, list):\n+        return [strip_nullable_unions(item, keep_nullable_hint=keep_nullable_hint) for item in schema]\n+    if not isinstance(schema, dict):\n+        return schema\n+\n+    stripped = {\n+        k: strip_nullable_unions(v, keep_nullable_hint=keep_nullable_hint)\n+        for k, v in schema.items()\n+    }\n+    for key in (\"anyOf\", \"oneOf\"):\n+        variants = stripped.get(key)\n+        if not isinstance(variants, list):\n+            continue\n+        non_null = [\n+            item for item in variants\n+            if not (isinstance(item, dict) and item.get(\"type\") == \"null\")\n+        ]\n+        # Only collapse when we actually dropped a null branch AND exactly\n+        # one non-null branch survives (otherwise the union is meaningful\n+        # and we leave it alone).\n+        if len(non_null) == 1 and len(non_null) != len(variants):\n+            replacement = dict(non_null[0]) if isinstance(non_null[0], dict) else {}\n+            if keep_nullable_hint:\n+                replacement.setdefault(\"nullable\", True)\n+            for meta_key in (\"title\", \"description\", \"default\", \"examples\"):\n+                if meta_key in stripped and meta_key not in replacement:\n+                    replacement[meta_key] = stripped[meta_key]\n+            return strip_nullable_unions(replacement, keep_nullable_hint=keep_nullable_hint)\n+    return stripped\n+\n+\n def _sanitize_node(node: Any, path: str) -> Any:\n     \"\"\"Recursively sanitize a JSON-Schema fragment.\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}