From 522f0b96521b352348c664aad6ca660bc6d99ef2 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Tue, 4 Aug 2026 15:40:16 +0200 Subject: [PATCH 1/5] fix(mcp): close gaps in #4367's tool reconciliation - Wire on_tools_reconciled through MCPToolProvider/create_mcp_tools() instead of attaching it to the client after create_tools() returns. DefaultMCPToolProvider and SettingsBackedMCPToolProvider never forwarded it, so any notifications/tools/list_changed that arrived during the initial connect were silently dropped (reproduced deterministically against a live FastMCP server). - Stop mutating Agent._tools in place in add_runtime_tools / _on_mcp_tools_changed. Agent.model_copy() shares private attrs by reference, so in-place mutation leaked dynamically-added MCP tools into other Agent snapshots; replace the dict instead, matching _on_mcp_tools_reconciled. - Remove an unused tool_names assignment in AgentBase._initialize. - Bound the per-schema MCP validation-model cache (LRU, 512 entries) so a tool whose schema changes repeatedly can't grow it without limit. --- .../openhands/agent_server/mcp_oauth_store.py | 3 + openhands-sdk/openhands/sdk/agent/base.py | 16 +++-- .../conversation/impl/local_conversation.py | 2 +- openhands-sdk/openhands/sdk/mcp/tool.py | 9 ++- openhands-sdk/openhands/sdk/mcp/utils.py | 9 ++- tests/agent_server/test_mcp_oauth_store.py | 23 +++++++ tests/sdk/agent/test_filter_tools_regex.py | 3 +- .../test_local_conversation_mcp.py | 2 + .../test_local_conversation_plugins.py | 5 +- tests/sdk/mcp/test_mcp_tool.py | 22 +++++++ tests/sdk/mcp/test_mcp_tool_list_changed.py | 60 ++++++++++++++++++- 11 files changed, 141 insertions(+), 13 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/mcp_oauth_store.py b/openhands-agent-server/openhands/agent_server/mcp_oauth_store.py index 609e80201a..945670ae12 100644 --- a/openhands-agent-server/openhands/agent_server/mcp_oauth_store.py +++ b/openhands-agent-server/openhands/agent_server/mcp_oauth_store.py @@ -26,6 +26,7 @@ ) from openhands.sdk.mcp.utils import ( ToolsChangedCallback, + ToolsReconciledCallback, create_mcp_tools, ) @@ -337,12 +338,14 @@ def create_tools( timeout: float = 30.0, *, on_tools_changed: ToolsChangedCallback | None = None, + on_tools_reconciled: ToolsReconciledCallback | None = None, ) -> MCPClient: return create_mcp_tools( mcp_config, timeout, mcp_oauth_token_storage=MCPSettingsOAuthTokenStore(), on_tools_changed=on_tools_changed, + on_tools_reconciled=on_tools_reconciled, ) diff --git a/openhands-sdk/openhands/sdk/agent/base.py b/openhands-sdk/openhands/sdk/agent/base.py index c2d210235a..1e60db8487 100644 --- a/openhands-sdk/openhands/sdk/agent/base.py +++ b/openhands-sdk/openhands/sdk/agent/base.py @@ -564,7 +564,6 @@ def _initialize( if self.filter_tools_regex: pattern = re.compile(self.filter_tools_regex) tools = [tool for tool in tools if pattern.match(tool.name)] - tool_names = [tool.name for tool in tools] logger.info("Filtered to %d tools after applying regex filter", len(tools)) # Include default tools from include_default_tools; not subject to regex @@ -876,9 +875,10 @@ def add_runtime_tools(self, tools: Sequence[ToolDefinition]) -> None: if existing: raise ValueError(f"Duplicate tool names found: {existing}") - # AgentBase is frozen, so update its mutable tool map in place. - for tool in tools: - self._tools[tool.name] = tool + # AgentBase is frozen; replace the tool map rather than mutating + # it in place, so Agent.model_copy() snapshots don't share state. + updated = {**self._tools, **{tool.name: tool for tool in tools}} + object.__setattr__(self, "_tools", updated) def _on_mcp_tools_changed(self, tools: Sequence[ToolDefinition]) -> None: """Handle dynamically advertised MCP tools. @@ -930,8 +930,12 @@ def _on_mcp_tools_changed(self, tools: Sequence[ToolDefinition]) -> None: ) self.add_runtime_tools(additions) - for tool in replacements: - self._tools[tool.name] = tool + if replacements: + updated = { + **self._tools, + **{tool.name: tool for tool in replacements}, + } + object.__setattr__(self, "_tools", updated) if additions: logger.info( diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 02c55f0523..49ffa734f3 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -1292,8 +1292,8 @@ def _runtime_mcp_tools( mcp_config, _RUNTIME_MCP_TIMEOUT_SECS, on_tools_changed=on_tools_changed, + on_tools_reconciled=on_tools_reconciled, ) - client._tools_reconciled_callback = on_tools_reconciled return list(client.tools) def _on_mcp_tools_reconciled( diff --git a/openhands-sdk/openhands/sdk/mcp/tool.py b/openhands-sdk/openhands/sdk/mcp/tool.py index f81b509d3d..139a626bd5 100644 --- a/openhands-sdk/openhands/sdk/mcp/tool.py +++ b/openhands-sdk/openhands/sdk/mcp/tool.py @@ -3,6 +3,7 @@ import copy import json import re +from collections import OrderedDict from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -196,7 +197,10 @@ def close(self) -> None: self.client.sync_close() -_mcp_dynamic_action_type: dict[tuple[str, str], type[Schema]] = {} +_MCP_ACTION_TYPE_CACHE_MAX = 512 +# LRU-bounded: keyed by (name, schema), so a tool whose schema keeps changing +# no longer grows this cache without limit. +_mcp_dynamic_action_type: OrderedDict[tuple[str, str], type[Schema]] = OrderedDict() def _create_mcp_action_type(action_type: mcp.types.Tool) -> type[Schema]: @@ -220,11 +224,14 @@ def _create_mcp_action_type(action_type: mcp.types.Tool) -> type[Schema]: ) mcp_action_type = _mcp_dynamic_action_type.get(cache_key) if mcp_action_type: + _mcp_dynamic_action_type.move_to_end(cache_key) return mcp_action_type model_name = f"MCP{to_camel_case(action_type.name)}Action" mcp_action_type = Schema.from_mcp_schema(model_name, action_type.inputSchema) _mcp_dynamic_action_type[cache_key] = mcp_action_type + if len(_mcp_dynamic_action_type) > _MCP_ACTION_TYPE_CACHE_MAX: + _mcp_dynamic_action_type.popitem(last=False) return mcp_action_type diff --git a/openhands-sdk/openhands/sdk/mcp/utils.py b/openhands-sdk/openhands/sdk/mcp/utils.py index 46a740935a..b05f58af1a 100644 --- a/openhands-sdk/openhands/sdk/mcp/utils.py +++ b/openhands-sdk/openhands/sdk/mcp/utils.py @@ -46,6 +46,7 @@ def create_tools( timeout: float = 30.0, *, on_tools_changed: ToolsChangedCallback | None = None, + on_tools_reconciled: ToolsReconciledCallback | None = None, ) -> MCPClient: ... @@ -58,8 +59,14 @@ def create_tools( timeout: float = 30.0, *, on_tools_changed: ToolsChangedCallback | None = None, + on_tools_reconciled: ToolsReconciledCallback | None = None, ) -> MCPClient: - return create_mcp_tools(mcp_config, timeout, on_tools_changed=on_tools_changed) + return create_mcp_tools( + mcp_config, + timeout, + on_tools_changed=on_tools_changed, + on_tools_reconciled=on_tools_reconciled, + ) def _oauth_auth_from_authentication_config( diff --git a/tests/agent_server/test_mcp_oauth_store.py b/tests/agent_server/test_mcp_oauth_store.py index ede13da989..1a551f9825 100644 --- a/tests/agent_server/test_mcp_oauth_store.py +++ b/tests/agent_server/test_mcp_oauth_store.py @@ -6,6 +6,7 @@ import threading import time from pathlib import Path +from unittest.mock import patch from urllib.parse import parse_qs, urlparse import httpx @@ -20,6 +21,7 @@ from openhands.agent_server.config import Config from openhands.agent_server.mcp_oauth_store import ( MCPSettingsOAuthTokenStore, + SettingsBackedMCPToolProvider, create_settings_backed_mcp_tool_provider, ) from openhands.agent_server.persistence import ( @@ -362,3 +364,24 @@ async def test_mcp_oauth_token_storage_does_not_attach_to_non_oauth_server( assert "auth" not in server finally: reset_stores() + + +def test_settings_backed_provider_forwards_on_tools_reconciled(): + """on_tools_reconciled must reach create_mcp_tools(), not be dropped. + + Previously this provider only accepted on_tools_changed, so callers had + to attach on_tools_reconciled to the returned client after the fact -- + missing any notification that arrived during the initial connect. + """ + provider = SettingsBackedMCPToolProvider() + config = coerce_mcp_config({"fake": {"command": "true"}}) + + def callback(client, tools): + return None + + with patch( + "openhands.agent_server.mcp_oauth_store.create_mcp_tools" + ) as mock_create: + provider.create_tools(config, on_tools_reconciled=callback) + + assert mock_create.call_args.kwargs["on_tools_reconciled"] is callback diff --git a/tests/sdk/agent/test_filter_tools_regex.py b/tests/sdk/agent/test_filter_tools_regex.py index 970d81465f..29d68843a3 100644 --- a/tests/sdk/agent/test_filter_tools_regex.py +++ b/tests/sdk/agent/test_filter_tools_regex.py @@ -8,7 +8,7 @@ import uuid from collections.abc import Sequence -from typing import ClassVar, cast +from typing import Any, ClassVar, cast import pytest @@ -252,6 +252,7 @@ def create_tools( timeout: float = 30.0, *, on_tools_changed: ToolsChangedCallback | None = None, + on_tools_reconciled: Any = None, ) -> MCPClient: return cast( MCPClient, diff --git a/tests/sdk/conversation/test_local_conversation_mcp.py b/tests/sdk/conversation/test_local_conversation_mcp.py index 4a78ceccd2..d0169d564a 100644 --- a/tests/sdk/conversation/test_local_conversation_mcp.py +++ b/tests/sdk/conversation/test_local_conversation_mcp.py @@ -35,8 +35,10 @@ def create_tools( timeout: float = 30.0, *, on_tools_changed: Any = None, + on_tools_reconciled: Any = None, ) -> MCPClient: self.calls.append(mcp_config) + self.client._tools_reconciled_callback = on_tools_reconciled return cast(MCPClient, self.client) diff --git a/tests/sdk/conversation/test_local_conversation_plugins.py b/tests/sdk/conversation/test_local_conversation_plugins.py index 4413eea924..67dc4f75ff 100644 --- a/tests/sdk/conversation/test_local_conversation_plugins.py +++ b/tests/sdk/conversation/test_local_conversation_plugins.py @@ -31,6 +31,7 @@ class EmptyMCPClient: def __init__(self): self.tools = [] + self._tools_reconciled_callback: Any = None class RecordingMCPToolProvider: @@ -41,7 +42,7 @@ def __init__( state_locked: Callable[[], bool] | None = None, ): self.created = created - self.client = client or EmptyMCPClient() + self.client: Any = client or EmptyMCPClient() self.state_locked = state_locked def create_tools( @@ -50,11 +51,13 @@ def create_tools( timeout: float = 30.0, *, on_tools_changed: Any = None, + on_tools_reconciled: Any = None, ) -> MCPClient: if self.state_locked is None: self.created.append(mcp_config) else: self.created.append((mcp_config, self.state_locked())) + self.client._tools_reconciled_callback = on_tools_reconciled return cast(MCPClient, self.client) diff --git a/tests/sdk/mcp/test_mcp_tool.py b/tests/sdk/mcp/test_mcp_tool.py index d1d2289698..53f3cc6336 100644 --- a/tests/sdk/mcp/test_mcp_tool.py +++ b/tests/sdk/mcp/test_mcp_tool.py @@ -440,3 +440,25 @@ def test_executor_assignment(self): assert isinstance(self.tool.executor, MCPToolExecutor) assert self.tool.executor.tool_name == "test_tool" assert self.tool.executor.client == self.mock_client + + +def test_action_type_cache_is_bounded(): + """A tool whose schema keeps changing must not grow the cache forever.""" + from openhands.sdk.mcp.tool import ( + _MCP_ACTION_TYPE_CACHE_MAX, + _create_mcp_action_type, + _mcp_dynamic_action_type, + ) + + for i in range(_MCP_ACTION_TYPE_CACHE_MAX + 50): + tool = mcp.types.Tool( + name="churning_tool", + description="d", + inputSchema={ + "type": "object", + "properties": {f"field_{i}": {"type": "string"}}, + }, + ) + _create_mcp_action_type(tool) + + assert len(_mcp_dynamic_action_type) <= _MCP_ACTION_TYPE_CACHE_MAX diff --git a/tests/sdk/mcp/test_mcp_tool_list_changed.py b/tests/sdk/mcp/test_mcp_tool_list_changed.py index fa4e57d1f9..6690f8b46a 100644 --- a/tests/sdk/mcp/test_mcp_tool_list_changed.py +++ b/tests/sdk/mcp/test_mcp_tool_list_changed.py @@ -25,10 +25,11 @@ import pytest from fastmcp import FastMCP from fastmcp.server.dependencies import get_context -from pydantic import ValidationError +from pydantic import SecretStr, ValidationError +from openhands.sdk.agent import Agent from openhands.sdk.agent.base import AgentBase -from openhands.sdk.llm import TextContent +from openhands.sdk.llm import LLM, TextContent from openhands.sdk.mcp import MCPClient, create_mcp_tools from openhands.sdk.mcp.config import coerce_mcp_config from openhands.sdk.mcp.tool import MCPToolDefinition @@ -339,6 +340,40 @@ def test_no_callback_still_connects(progressive_server: int): assert "register_extra_tool" in names +def test_default_provider_wires_on_tools_reconciled_before_connect( + progressive_server: int, +): + """DefaultMCPToolProvider must forward on_tools_reconciled to + create_mcp_tools() so it's attached before the client connects, instead + of being set on the client after create_tools() already returned (which + would drop any notification that arrives during the initial connect). + """ + from openhands.sdk.mcp.utils import DefaultMCPToolProvider + + port = progressive_server + config = _native_config( + { + "mcpServers": { + "progressive": { + "transport": "http", + "url": f"http://127.0.0.1:{port}/mcp", + } + } + } + ) + + def on_tools_reconciled(client, tools): # noqa: ANN001 + pass + + client = DefaultMCPToolProvider().create_tools( + config, timeout=10.0, on_tools_reconciled=on_tools_reconciled + ) + try: + assert client._tools_reconciled_callback is on_tools_reconciled + finally: + client.sync_close() + + def test_list_changed_notification_reconciles_readded_agent_tool( progressive_server: int, ): @@ -446,6 +481,27 @@ def test_on_mcp_tools_changed_registers_runtime_tools(): assert agent.tools_map["dynamic"] is tool +def test_add_runtime_tools_does_not_leak_into_model_copy(): + """Registering a tool on a copied Agent must not mutate the original. + + Agent.model_copy() shares private-attr objects (e.g. _tools) by + reference, so mutating the tool map in place would leak across copies. + """ + original = Agent(llm=LLM(model="test-model", api_key=SecretStr("k")), tools=[]) + original._initialized = True + copy = original.model_copy() + client = _FakeClient([]) + tool = MCPToolDefinition.create( + mcp_tool=_make_mcp_tool("dynamic"), + mcp_client=cast(MCPClient, client), + )[0] + + copy.add_runtime_tools([tool]) + + assert "dynamic" in copy.tools_map + assert "dynamic" not in original.tools_map + + def test_on_mcp_tools_changed_skips_when_not_initialized(): """Before initialization, notifications are dropped, not crashed on.""" agent = _ConcreteAgent(_initialized=False, _tools=None) From 319a9d026a3a775f6fe9109df236902e15b42452 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Tue, 4 Aug 2026 15:43:44 +0200 Subject: [PATCH 2/5] fix(mcp): mark _MCP_ACTION_TYPE_CACHE_MAX as Final --- openhands-sdk/openhands/sdk/mcp/tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/mcp/tool.py b/openhands-sdk/openhands/sdk/mcp/tool.py index 139a626bd5..5625e933c6 100644 --- a/openhands-sdk/openhands/sdk/mcp/tool.py +++ b/openhands-sdk/openhands/sdk/mcp/tool.py @@ -5,7 +5,7 @@ import re from collections import OrderedDict from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final if TYPE_CHECKING: @@ -197,7 +197,7 @@ def close(self) -> None: self.client.sync_close() -_MCP_ACTION_TYPE_CACHE_MAX = 512 +_MCP_ACTION_TYPE_CACHE_MAX: Final[int] = 512 # LRU-bounded: keyed by (name, schema), so a tool whose schema keeps changing # no longer grows this cache without limit. _mcp_dynamic_action_type: OrderedDict[tuple[str, str], type[Schema]] = OrderedDict() From c9fda655c92c792e4533cd0db52e4d5eb78b7d5c Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Tue, 4 Aug 2026 16:06:00 +0200 Subject: [PATCH 3/5] fix(mcp): preserve provider compatibility, make action-type cache thread-safe Follow-up to the two issues flagged in code review on this branch: - LocalConversation unconditionally passed on_tools_reconciled to MCPToolProvider.create_tools(), breaking any custom provider written against the pre-existing protocol (only on_tools_changed) with a TypeError on first MCP connection. Check the provider's signature via provider_supports_on_tools_reconciled() and omit the keyword for providers that don't accept it. - _create_mcp_action_type's cache-hit path did .get() then .move_to_end() as two separate steps; a concurrent eviction landing in between raised KeyError. Guard the whole get/move/insert/evict sequence with a lock, since MCP tool calls can validate concurrently through the parallel tool executor. --- .../conversation/impl/local_conversation.py | 15 +++-- openhands-sdk/openhands/sdk/mcp/tool.py | 26 ++++---- openhands-sdk/openhands/sdk/mcp/utils.py | 18 ++++++ .../test_local_conversation_mcp.py | 38 ++++++++++++ tests/sdk/mcp/test_mcp_tool.py | 60 +++++++++++++++++++ 5 files changed, 142 insertions(+), 15 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index d16a253f67..8fe0017678 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -72,6 +72,7 @@ MCPToolProvider, ToolsChangedCallback, ToolsReconciledCallback, + provider_supports_on_tools_reconciled, ) from openhands.sdk.observability.laminar import OPERATION_METADATA_KEY, observe from openhands.sdk.plugin import ( @@ -1288,11 +1289,17 @@ def _runtime_mcp_tools( mcp_config = enabled_mcp_servers(mcp_config) if not mcp_config: return [] + create_kwargs: dict[str, Any] = {"on_tools_changed": on_tools_changed} + if provider_supports_on_tools_reconciled(self._mcp_tool_provider): + create_kwargs["on_tools_reconciled"] = on_tools_reconciled + elif on_tools_reconciled is not None: + logger.debug( + "%s does not accept on_tools_reconciled; dynamic MCP tool " + "removals/updates won't reach the agent for this provider", + type(self._mcp_tool_provider).__name__, + ) client = self._mcp_tool_provider.create_tools( - mcp_config, - _RUNTIME_MCP_TIMEOUT_SECS, - on_tools_changed=on_tools_changed, - on_tools_reconciled=on_tools_reconciled, + mcp_config, _RUNTIME_MCP_TIMEOUT_SECS, **create_kwargs ) return list(client.tools) diff --git a/openhands-sdk/openhands/sdk/mcp/tool.py b/openhands-sdk/openhands/sdk/mcp/tool.py index 5625e933c6..394edb37b5 100644 --- a/openhands-sdk/openhands/sdk/mcp/tool.py +++ b/openhands-sdk/openhands/sdk/mcp/tool.py @@ -3,6 +3,7 @@ import copy import json import re +import threading from collections import OrderedDict from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final @@ -199,8 +200,10 @@ def close(self) -> None: _MCP_ACTION_TYPE_CACHE_MAX: Final[int] = 512 # LRU-bounded: keyed by (name, schema), so a tool whose schema keeps changing -# no longer grows this cache without limit. +# no longer grows this cache without limit. Guarded by a lock since MCP tool +# calls can validate concurrently through the parallel tool executor. _mcp_dynamic_action_type: OrderedDict[tuple[str, str], type[Schema]] = OrderedDict() +_mcp_dynamic_action_type_lock = threading.Lock() def _create_mcp_action_type(action_type: mcp.types.Tool) -> type[Schema]: @@ -222,18 +225,19 @@ def _create_mcp_action_type(action_type: mcp.types.Tool) -> type[Schema]: action_type.name, json.dumps(action_type.inputSchema, sort_keys=True, separators=(",", ":")), ) - mcp_action_type = _mcp_dynamic_action_type.get(cache_key) - if mcp_action_type: - _mcp_dynamic_action_type.move_to_end(cache_key) + with _mcp_dynamic_action_type_lock: + mcp_action_type = _mcp_dynamic_action_type.get(cache_key) + if mcp_action_type: + _mcp_dynamic_action_type.move_to_end(cache_key) + return mcp_action_type + + model_name = f"MCP{to_camel_case(action_type.name)}Action" + mcp_action_type = Schema.from_mcp_schema(model_name, action_type.inputSchema) + _mcp_dynamic_action_type[cache_key] = mcp_action_type + if len(_mcp_dynamic_action_type) > _MCP_ACTION_TYPE_CACHE_MAX: + _mcp_dynamic_action_type.popitem(last=False) return mcp_action_type - model_name = f"MCP{to_camel_case(action_type.name)}Action" - mcp_action_type = Schema.from_mcp_schema(model_name, action_type.inputSchema) - _mcp_dynamic_action_type[cache_key] = mcp_action_type - if len(_mcp_dynamic_action_type) > _MCP_ACTION_TYPE_CACHE_MAX: - _mcp_dynamic_action_type.popitem(last=False) - return mcp_action_type - class MCPToolDefinition(ToolDefinition[MCPToolAction, MCPToolObservation]): """MCP Tool that wraps an MCP client and provides tool functionality.""" diff --git a/openhands-sdk/openhands/sdk/mcp/utils.py b/openhands-sdk/openhands/sdk/mcp/utils.py index b05f58af1a..c8749de7aa 100644 --- a/openhands-sdk/openhands/sdk/mcp/utils.py +++ b/openhands-sdk/openhands/sdk/mcp/utils.py @@ -1,6 +1,7 @@ """Utility functions for MCP integration.""" import asyncio +import inspect import logging from collections.abc import Callable, Mapping, Sequence from typing import Protocol @@ -69,6 +70,23 @@ def create_tools( ) +def provider_supports_on_tools_reconciled(provider: MCPToolProvider) -> bool: + """Whether ``provider.create_tools`` accepts ``on_tools_reconciled``. + + Custom ``MCPToolProvider`` implementations written before this parameter + existed only accept ``on_tools_changed``; passing the new keyword to + them would raise ``TypeError``. Callers should check this first and omit + the keyword for providers that don't support it. + """ + try: + params = inspect.signature(provider.create_tools).parameters + except (TypeError, ValueError): + return False + return "on_tools_reconciled" in params or any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + + def _oauth_auth_from_authentication_config( authentication: MCPOAuthAuthentication | None, *, diff --git a/tests/sdk/conversation/test_local_conversation_mcp.py b/tests/sdk/conversation/test_local_conversation_mcp.py index d0169d564a..d181ba00fa 100644 --- a/tests/sdk/conversation/test_local_conversation_mcp.py +++ b/tests/sdk/conversation/test_local_conversation_mcp.py @@ -11,6 +11,7 @@ from openhands.sdk.mcp.client import MCPClient from openhands.sdk.mcp.config import MCPServer, coerce_mcp_config from openhands.sdk.mcp.tool import MCPToolDefinition +from openhands.sdk.mcp.utils import MCPToolProvider class EmptyMCPClient: @@ -102,3 +103,40 @@ def test_reconciliation_targets_replaced_agent(tmp_path: Path) -> None: assert set(conversation.agent.tools_map) == {"replacement"} assert set(old_agent.tools_map) == {"initial"} conversation.close() + + +class LegacyMCPToolProvider: + """A custom provider written against the pre-reconciliation protocol.""" + + def create_tools( + self, + mcp_config: dict[str, MCPServer], + timeout: float = 30.0, + *, + on_tools_changed: Any = None, + ) -> MCPClient: + return cast(MCPClient, EmptyMCPClient()) + + +def test_legacy_provider_without_on_tools_reconciled_still_works( + tmp_path: Path, +) -> None: + """A custom MCPToolProvider that predates on_tools_reconciled must not + break; it just won't receive full-snapshot reconciliation.""" + conversation = LocalConversation( + agent=Agent( + llm=LLM(model="test-model", api_key=SecretStr("test-key")), + tools=[], + include_default_tools=[], + mcp_config=coerce_mcp_config({"fake": {"command": "true"}}), + ), + workspace=str(tmp_path), + visualizer=None, + # Deliberately incompatible with the current MCPToolProvider + # protocol shape; that's the scenario under test. + mcp_tool_provider=cast(MCPToolProvider, LegacyMCPToolProvider()), + ) + + conversation._ensure_agent_ready() + + conversation.close() diff --git a/tests/sdk/mcp/test_mcp_tool.py b/tests/sdk/mcp/test_mcp_tool.py index 53f3cc6336..de465497da 100644 --- a/tests/sdk/mcp/test_mcp_tool.py +++ b/tests/sdk/mcp/test_mcp_tool.py @@ -462,3 +462,63 @@ def test_action_type_cache_is_bounded(): _create_mcp_action_type(tool) assert len(_mcp_dynamic_action_type) <= _MCP_ACTION_TYPE_CACHE_MAX + + +def test_action_type_cache_serializes_get_and_evict(monkeypatch): + """A cache hit must not observe a concurrent eviction of the same key. + + Forces the exact interleaving a real race could produce: pause inside + the cache-hit path (after `.get()`, before `.move_to_end()`) and let a + second thread try to evict that same entry. Without the lock this + raises KeyError from `move_to_end`; with it, the second thread blocks + until the first thread's critical section completes. + """ + import threading + import time + from collections import OrderedDict + + import openhands.sdk.mcp.tool as tool_module + + paused = threading.Event() + + class PausingDict(OrderedDict): + def get(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 + result = super().get(*args, **kwargs) + if result is not None and not paused.is_set(): + paused.set() + time.sleep(0.3) + return result + + monkeypatch.setattr(tool_module, "_MCP_ACTION_TYPE_CACHE_MAX", 1) + monkeypatch.setattr(tool_module, "_mcp_dynamic_action_type", PausingDict()) + + shared_tool = mcp.types.Tool( + name="shared", description="d", inputSchema={"type": "object"} + ) + other_tool = mcp.types.Tool( + name="other", description="d", inputSchema={"type": "object"} + ) + tool_module._create_mcp_action_type(shared_tool) # seed the cache + + errors: list[Exception] = [] + + def hit(): + try: + tool_module._create_mcp_action_type(shared_tool) + except Exception as e: # noqa: BLE001 + errors.append(e) + + def evict(): + paused.wait(2.0) + try: + tool_module._create_mcp_action_type(other_tool) + except Exception as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=hit), threading.Thread(target=evict)] + for t in threads: + t.start() + for t in threads: + t.join(5.0) + + assert not errors From 40d6a07ed6496a2bdbb9cf63425105e3f8de13a7 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Tue, 4 Aug 2026 16:43:14 +0200 Subject: [PATCH 4/5] test(mcp): cover provider_supports_on_tools_reconciled directly Adds direct coverage for the explicit-param, **kwargs, and unsupported-signature cases, on top of the indirect coverage from the legacy-provider integration test. --- .../test_local_conversation_mcp.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/sdk/conversation/test_local_conversation_mcp.py b/tests/sdk/conversation/test_local_conversation_mcp.py index d181ba00fa..26065f25eb 100644 --- a/tests/sdk/conversation/test_local_conversation_mcp.py +++ b/tests/sdk/conversation/test_local_conversation_mcp.py @@ -140,3 +140,26 @@ def test_legacy_provider_without_on_tools_reconciled_still_works( conversation._ensure_agent_ready() conversation.close() + + +class _KwargsMCPToolProvider: + """A provider that accepts arbitrary keywords via **kwargs.""" + + def create_tools( + self, mcp_config: dict[str, MCPServer], timeout: float = 30.0, **kwargs: Any + ) -> MCPClient: + return cast(MCPClient, EmptyMCPClient()) + + +def test_provider_supports_on_tools_reconciled() -> None: + from openhands.sdk.mcp.utils import ( + DefaultMCPToolProvider, + provider_supports_on_tools_reconciled, + ) + + assert provider_supports_on_tools_reconciled(DefaultMCPToolProvider()) + assert provider_supports_on_tools_reconciled(RecordingMCPToolProvider()) + assert provider_supports_on_tools_reconciled(_KwargsMCPToolProvider()) + assert not provider_supports_on_tools_reconciled( + cast(MCPToolProvider, LegacyMCPToolProvider()) + ) From 9f7327ad9505cf28d3fe5bd526d050c214c014f3 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Wed, 5 Aug 2026 12:33:44 +0200 Subject: [PATCH 5/5] fix(mcp): tolerate startup-race tool installed before add_runtime_tools returns A notifications/tools/list_changed notification arriving while the initial create_tools() call is still in flight can install a tool via on_tools_changed/on_tools_reconciled before the caller's own add_runtime_tools() call runs with the client's returned snapshot, raising a spurious duplicate-tool-names ValueError and crashing agent initialization. Exempt tools already installed by the same MCPClient, matching the same-client check already used in _on_mcp_tools_changed/_on_mcp_tools_reconciled. --- openhands-sdk/openhands/sdk/agent/base.py | 27 ++++++++++++++++--- tests/sdk/mcp/test_mcp_tool_list_changed.py | 30 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/base.py b/openhands-sdk/openhands/sdk/agent/base.py index 1e60db8487..9dd26339d0 100644 --- a/openhands-sdk/openhands/sdk/agent/base.py +++ b/openhands-sdk/openhands/sdk/agent/base.py @@ -871,9 +871,30 @@ def add_runtime_tools(self, tools: Sequence[ToolDefinition]) -> None: } raise ValueError(f"Duplicate runtime tool names found: {duplicates}") with self._tools_lock: - existing = set(self._tools) & set(tool_names) - if existing: - raise ValueError(f"Duplicate tool names found: {existing}") + # A tools/list_changed notification can race the caller: if it + # arrives while the provider's initial create_tools() call is + # still in flight, _on_mcp_tools_changed/_on_mcp_tools_reconciled + # may already have installed the same tool via this same client + # before the caller's own add_runtime_tools() call (using the + # client's returned snapshot) gets here. Treat that as a refresh, + # not a conflict, matching the same-client exemption already used + # by _on_mcp_tools_changed/_on_mcp_tools_reconciled. + conflicts: set[str] = set() + for tool in tools: + existing_tool = self._tools.get(tool.name) + if existing_tool is None: + continue + existing_executor = existing_tool.executor + replacement_executor = tool.executor + if ( + isinstance(existing_executor, MCPToolExecutor) + and isinstance(replacement_executor, MCPToolExecutor) + and existing_executor.client is replacement_executor.client + ): + continue + conflicts.add(tool.name) + if conflicts: + raise ValueError(f"Duplicate tool names found: {conflicts}") # AgentBase is frozen; replace the tool map rather than mutating # it in place, so Agent.model_copy() snapshots don't share state. diff --git a/tests/sdk/mcp/test_mcp_tool_list_changed.py b/tests/sdk/mcp/test_mcp_tool_list_changed.py index 6690f8b46a..efcd1f2737 100644 --- a/tests/sdk/mcp/test_mcp_tool_list_changed.py +++ b/tests/sdk/mcp/test_mcp_tool_list_changed.py @@ -502,6 +502,36 @@ def test_add_runtime_tools_does_not_leak_into_model_copy(): assert "dynamic" not in original.tools_map +def test_add_runtime_tools_tolerates_notification_installed_before_return(): + """A tool installed mid-connect by the reconciliation callback must not + collide with the caller's own add_runtime_tools() call. + + If notifications/tools/list_changed arrives while the initial + tools/list request is still in flight, on_tools_changed can install the + tool via this same MCPClient before create_tools() returns. The caller + (e.g. LocalConversation._ensure_agent_ready()) then calls + add_runtime_tools() again with the client's returned snapshot, which + already contains that tool; this must be treated as a refresh rather + than a duplicate-tool conflict. + """ + agent = _ConcreteAgent(_initialized=True, _tools={}) + client = _FakeClient([]) + tool = MCPToolDefinition.create( + mcp_tool=_make_mcp_tool("only_tool"), + mcp_client=cast(MCPClient, client), + )[0] + + # Simulates the mid-flight on_tools_changed callback installing the tool + # before create_tools() returns. + agent._on_mcp_tools_changed([tool]) + + # Simulates the caller's add_runtime_tools() call using the client's + # returned snapshot, which already includes the same tool. + agent.add_runtime_tools([tool]) + + assert agent.tools_map["only_tool"] is tool + + def test_on_mcp_tools_changed_skips_when_not_initialized(): """Before initialization, notifications are dropped, not crashed on.""" agent = _ConcreteAgent(_initialized=False, _tools=None)