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
- 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
Open PRs0
Fixes on record
-
#16978 fix(mcp): normalize nullable tool schemas + avoid list_changed RPC races
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.pyShow 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.00means 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 LabAt 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.