diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index 6e252f667c..a8070f89a6 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -550,6 +550,22 @@ async def load_conversation_plugin( return Success() +@conversation_router.post( + "/{conversation_id}/refresh_mcp_tools", + responses={404: {"description": "Conversation not found"}}, +) +async def refresh_conversation_mcp_tools( + conversation_id: UUID, + conversation_service: ConversationService = Depends(get_conversation_service), +) -> Success: + """Re-fetch MCP tools for an active conversation.""" + event_service = await conversation_service.get_event_service(conversation_id) + if event_service is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + await event_service.refresh_mcp_tools() + return Success() + + @conversation_router.post( "/{conversation_id}/switch_acp_model", responses={ diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py index 141bf318f9..a7235b8bdb 100644 --- a/openhands-agent-server/openhands/agent_server/event_service.py +++ b/openhands-agent-server/openhands/agent_server/event_service.py @@ -1616,6 +1616,13 @@ async def load_plugin(self, plugin_ref: str) -> None: loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._conversation.load_plugin, plugin_ref) + async def refresh_mcp_tools(self) -> None: + """Refresh MCP tools without blocking the agent-server event loop.""" + if self._conversation is None: + raise ValueError("inactive_service") + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._conversation.refresh_mcp_tools) + async def switch_acp_model(self, model: str) -> None: """Switch the model on an ACP conversation. diff --git a/openhands-sdk/openhands/sdk/conversation/base.py b/openhands-sdk/openhands/sdk/conversation/base.py index 3c681a9d24..a056afcd9e 100644 --- a/openhands-sdk/openhands/sdk/conversation/base.py +++ b/openhands-sdk/openhands/sdk/conversation/base.py @@ -407,6 +407,12 @@ def load_plugin(self, plugin_ref: str) -> None: """ raise NotImplementedError("This conversation does not support loading plugins") + def refresh_mcp_tools(self) -> None: + """Re-fetch every MCP tool list between conversation runs.""" + raise NotImplementedError( + "This conversation does not support refreshing MCP tools" + ) + @abstractmethod def fork( self, diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index e95bd734a1..ce83354ab8 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, + _refresh_mcp_client_tools, ) from openhands.sdk.observability.laminar import OPERATION_METADATA_KEY, observe from openhands.sdk.plugin import ( @@ -302,6 +303,7 @@ def __init__( self._agent_ready = False # Agent initialized lazily after plugins loaded self._subscription_disabled_condenser = None self._mcp_tool_provider = mcp_tool_provider or DefaultMCPToolProvider() + self._mcp_clients: list[MCPClient] = [] # Create-or-resume: factory inspects BASE_STATE to decide desired_id = conversation_id or uuid.uuid4() @@ -1275,26 +1277,27 @@ def _merge_runtime_plugin_hooks(self, plugin_hooks: HookConfig) -> None: self._hook_processor.set_conversation_state(self._state) self._hook_processor.run_session_start() - def _runtime_mcp_tools( + def _runtime_mcp_client( self, mcp_config: dict[str, MCPServer], *, on_tools_changed: ToolsChangedCallback | None = None, on_tools_reconciled: ToolsReconciledCallback | None = None, - ) -> list[ToolDefinition]: + ) -> MCPClient | None: # Servers the user switched off stay in the settings map but must not # be connected to. Filter before the emptiness check so an all-disabled # config is a plain no-op rather than a zero-server MCP client. mcp_config = enabled_mcp_servers(mcp_config) if not mcp_config: - return [] + return None client = self._mcp_tool_provider.create_tools( mcp_config, _RUNTIME_MCP_TIMEOUT_SECS, on_tools_changed=on_tools_changed, ) client._tools_reconciled_callback = on_tools_reconciled - return list(client.tools) + self._mcp_clients.append(client) + return client def _on_mcp_tools_reconciled( self, @@ -1303,10 +1306,10 @@ def _on_mcp_tools_reconciled( ) -> None: self.agent._on_mcp_tools_reconciled(client, tools) - def _runtime_mcp_tools_for_agent(self) -> list[ToolDefinition]: + def _runtime_mcp_client_for_agent(self) -> MCPClient | None: if not self.agent.supports_openhands_tools or not self.agent.mcp_config: - return [] - return self._runtime_mcp_tools( + return None + return self._runtime_mcp_client( self.agent.mcp_config, on_tools_changed=lambda tools: self.agent._on_mcp_tools_changed(tools), on_tools_reconciled=self._on_mcp_tools_reconciled, @@ -1325,18 +1328,23 @@ def _runtime_skill_tools_for_agent(self) -> list[ToolDefinition]: return list(InvokeSkillTool.create(self._state)) return [] - def _close_runtime_tools(self, tools: Sequence[ToolDefinition]) -> None: - for tool in tools: - try: - tool.as_executable().executor.close() - except NotImplementedError: - continue - except Exception as exc: - logger.warning( - "Error closing runtime tool executor for tool '%s': %s", - tool.name, - exc, - ) + def refresh_mcp_tools(self) -> None: + """Re-fetch and reconcile every MCP tool snapshot between runs.""" + if not self._agent_ready: + self._ensure_agent_ready() + return + for client in tuple(self._mcp_clients): + _refresh_mcp_client_tools( + client, + _RUNTIME_MCP_TIMEOUT_SECS, + on_tools_reconciled=self._on_mcp_tools_reconciled, + ) + + def _close_mcp_client(self, client: MCPClient | None) -> None: + if client is None: + return + client.sync_close() + self._mcp_clients.remove(client) def load_plugin(self, plugin_ref: str) -> None: """Load a plugin from the conversation's registered marketplaces.""" @@ -1378,14 +1386,14 @@ def load_plugin(self, plugin_ref: str) -> None: expand_defaults=True, ) merged_mcp = coerce_mcp_config(expanded_mcp["mcpServers"]) - runtime_mcp_tools = ( - self._runtime_mcp_tools( + runtime_mcp_client = ( + self._runtime_mcp_client( runtime_plugin_mcp, on_tools_changed=lambda tools: self.agent._on_mcp_tools_changed(tools), on_tools_reconciled=self._on_mcp_tools_reconciled, ) if self._agent_ready - else [] + else None ) with self._state: @@ -1411,6 +1419,9 @@ def load_plugin(self, plugin_ref: str) -> None: self._state.agent = self.agent if self._agent_ready: + runtime_mcp_tools = ( + runtime_mcp_client.tools if runtime_mcp_client is not None else [] + ) runtime_tools = [ *runtime_mcp_tools, *self._runtime_skill_tools_for_agent(), @@ -1418,7 +1429,7 @@ def load_plugin(self, plugin_ref: str) -> None: try: self.agent.add_runtime_tools(runtime_tools) except Exception: - self._close_runtime_tools(runtime_mcp_tools) + self._close_mcp_client(runtime_mcp_client) raise def _register_file_based_agents(self) -> None: @@ -1476,19 +1487,23 @@ def _ensure_agent_ready(self) -> None: # register file-based agents self._register_file_based_agents() - runtime_mcp_tools: list[ToolDefinition] = [] + runtime_mcp_client: MCPClient | None = None try: if self.agent.supports_openhands_tools: self.agent._initialize(self._state) - runtime_mcp_tools = self._runtime_mcp_tools_for_agent() - self.agent.add_runtime_tools(runtime_mcp_tools) + runtime_mcp_client = self._runtime_mcp_client_for_agent() + self.agent.add_runtime_tools( + runtime_mcp_client.tools + if runtime_mcp_client is not None + else [] + ) self.agent.init_state( self._state, on_event=self._on_event, ) except Exception: - self._close_runtime_tools(runtime_mcp_tools) + self._close_mcp_client(runtime_mcp_client) raise # Register LLMs in the registry (still holding lock). @@ -2647,6 +2662,9 @@ def close(self) -> None: self._end_observability_span() except AttributeError: pass + for client in self._mcp_clients: + client.sync_close() + self._mcp_clients.clear() # Clean up agent resources (e.g., ACPAgent subprocess) agent_error: Exception | None = None try: diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 97a0b8c07f..43a62f09a3 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -1455,6 +1455,14 @@ def load_plugin(self, plugin_ref: str) -> None: json={"plugin_ref": plugin_ref}, ) + def refresh_mcp_tools(self) -> None: + """Ask the remote server to refresh this conversation's MCP tools.""" + _send_request( + self._client, + "POST", + f"{self._conversation_action_base_path}/{self._id}/refresh_mcp_tools", + ) + def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: from openhands.sdk.secret.secrets import SecretSource diff --git a/openhands-sdk/openhands/sdk/mcp/client.py b/openhands-sdk/openhands/sdk/mcp/client.py index 898e85e16f..d59ad82cfb 100644 --- a/openhands-sdk/openhands/sdk/mcp/client.py +++ b/openhands-sdk/openhands/sdk/mcp/client.py @@ -41,6 +41,7 @@ class MCPClient(AsyncMCPClient): _executor: AsyncExecutor _closed: bool _tools: "list[MCPToolDefinition]" + _tools_refresh_lock: asyncio.Lock _tools_reconciled_callback: ToolsReconciledCallback | None def __init__(self, *args, **kwargs): @@ -48,6 +49,7 @@ def __init__(self, *args, **kwargs): self._executor = AsyncExecutor() self._closed = False self._tools = [] + self._tools_refresh_lock = asyncio.Lock() self._tools_reconciled_callback = None @property @@ -62,6 +64,13 @@ async def connect(self) -> None: except RuntimeError as exc: raise MCPError("MCP Connection Failure") from exc + async def _reconnect(self) -> None: + """Replace the current MCP session while preserving client configuration.""" + if self._closed: + raise MCPError("Cannot reconnect a closed MCP client") + await self.__aexit__(None, None, None) + await self.connect() + def call_async_from_sync( self, awaitable_or_fn: Callable[..., Any] | Any, diff --git a/openhands-sdk/openhands/sdk/mcp/utils.py b/openhands-sdk/openhands/sdk/mcp/utils.py index 46a740935a..c834872bf7 100644 --- a/openhands-sdk/openhands/sdk/mcp/utils.py +++ b/openhands-sdk/openhands/sdk/mcp/utils.py @@ -21,7 +21,7 @@ enabled_mcp_servers, to_fastmcp_mcp_config, ) -from openhands.sdk.mcp.exceptions import MCPTimeoutError +from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError from openhands.sdk.mcp.tool import MCPToolDefinition @@ -165,8 +165,48 @@ async def log_handler(message: LogMessage): async def _connect_and_list_tools(client: MCPClient) -> None: """Connect to MCP server and populate client._tools.""" - await client.connect() - await _refresh_tools(client) + await _refresh_connected_tools(client) + + +async def _refresh_connected_tools( + client: MCPClient, + on_tools_changed: ToolsChangedCallback | None = None, + on_tools_reconciled: ToolsReconciledCallback | None = None, +) -> None: + """Ensure the MCP session is connected, then refresh its tool snapshot.""" + async with client._tools_refresh_lock: + if client._closed: + raise MCPError("Cannot refresh tools on a closed MCP client") + if not client.is_connected(): + await client.connect() + await _refresh_tools(client, on_tools_changed, on_tools_reconciled) + + +async def _reconnect_and_refresh_tools( + client: MCPClient, + on_tools_reconciled: ToolsReconciledCallback | None = None, +) -> None: + """Replace an MCP session before re-listing its tools.""" + async with client._tools_refresh_lock: + await client._reconnect() + await _refresh_tools(client) + if on_tools_reconciled is not None: + on_tools_reconciled(client, client.tools) + + +def _refresh_mcp_client_tools( + client: MCPClient, + timeout: float = 30.0, + *, + on_tools_reconciled: ToolsReconciledCallback | None = None, +) -> None: + """Start a fresh MCP session and refresh its advertised tools.""" + client.call_async_from_sync( + _reconnect_and_refresh_tools, + timeout=timeout, + client=client, + on_tools_reconciled=on_tools_reconciled, + ) async def _refresh_tools( @@ -177,8 +217,8 @@ async def _refresh_tools( """Re-list tools from the server and reconcile ``client._tools``. Called after the initial connection and whenever the server sends a - ``notifications/tools/list_changed`` notification. When an - ``on_tools_changed`` preserves the original additions-only callback contract. + ``notifications/tools/list_changed`` notification. ``on_tools_changed`` + preserves the original additions-only callback contract. ``on_tools_reconciled`` receives the complete current snapshot so a running agent can add, replace, and remove tools owned by this client. """ @@ -253,7 +293,6 @@ def __init__( super().__init__() self._client = client self._on_tools_changed = on_tools_changed - self._refresh_lock = asyncio.Lock() self._refresh_tasks: set[asyncio.Task[None]] = set() async def on_tool_list_changed( @@ -272,14 +311,13 @@ async def on_tool_list_changed( async def _refresh_tools(self) -> None: client = self._client try: - async with self._refresh_lock: - if client._closed: - return - await _refresh_tools( - client, - self._on_tools_changed, - client._tools_reconciled_callback, - ) + if client._closed: + return + await _refresh_connected_tools( + client, + self._on_tools_changed, + client._tools_reconciled_callback, + ) except Exception: logger.warning( "Failed to refresh MCP tools after list_changed notification", diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py index 8b4e17d5d5..fde4255530 100644 --- a/tests/cross/test_remote_conversation_live_server.py +++ b/tests/cross/test_remote_conversation_live_server.py @@ -538,6 +538,8 @@ def test_remote_conversation_over_real_server(server_env, patched_llm): agent=agent, workspace=workspace ) # RemoteConversation + conv.refresh_mcp_tools() + # Send a message and run conv.send_message("Say hello") conv.run() diff --git a/tests/sdk/conversation/test_local_conversation_mcp.py b/tests/sdk/conversation/test_local_conversation_mcp.py index 4a78ceccd2..7c87eb155c 100644 --- a/tests/sdk/conversation/test_local_conversation_mcp.py +++ b/tests/sdk/conversation/test_local_conversation_mcp.py @@ -1,25 +1,145 @@ """Tests for how LocalConversation wires MCP servers into a running agent.""" +from __future__ import annotations + +import asyncio +import multiprocessing +import socket +import time +from collections.abc import Callable, Iterator, Sequence +from multiprocessing.process import BaseProcess from pathlib import Path from typing import Any, cast import mcp.types as mcp_types -from pydantic import SecretStr +import pytest +from fastmcp import FastMCP +from pydantic import PrivateAttr, SecretStr from openhands.sdk import LLM, Agent from openhands.sdk.conversation.impl.local_conversation import LocalConversation +from openhands.sdk.llm import Message, TextContent, TokenCallbackType +from openhands.sdk.llm.llm import LLMCallContext +from openhands.sdk.llm.llm_response import LLMResponse 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.testing import TestLLM +from openhands.sdk.tool.tool import ToolDefinition + + +class ToolRecordingLLM(TestLLM): + _tool_snapshots: list[list[str]] = PrivateAttr(default_factory=list) + + def completion( + self, + messages: list[Message], + tools: Sequence[ToolDefinition] | None = None, + add_security_risk_prediction: bool = False, + on_token: TokenCallbackType | None = None, + call_context: LLMCallContext | None = None, + **kwargs: Any, + ) -> LLMResponse: + self._tool_snapshots.append(sorted(tool.name for tool in tools or [])) + return super().completion( + messages, + tools, + add_security_risk_prediction, + on_token, + call_context, + **kwargs, + ) + + +def _run_mcp_deployment(port: int, deployment: str, stateless_http: bool) -> None: + server = FastMCP("refresh-test") + + if deployment == "old": + + @server.tool() + def old_tool() -> str: + """Tool removed by the next deployment.""" + return "old" + + @server.tool(name="changing") + def old_changing(old: str) -> str: + """Old schema.""" + return old + + else: + + @server.tool() + def new_tool() -> str: + """Tool added by the next deployment.""" + return "new" + + @server.tool(name="changing") + def new_changing(new: int) -> int: + """New schema.""" + return new + + asyncio.run( + server.run_http_async( + host="127.0.0.1", + port=port, + transport="http", + show_banner=False, + path="/mcp", + stateless_http=stateless_http, + ) + ) + + +@pytest.fixture +def deploy_mcp_server() -> Iterator[Callable[[str, bool], str]]: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + process: BaseProcess | None = None + + def stop() -> None: + nonlocal process + if process is None: + return + process.terminate() + process.join(timeout=10) + assert not process.is_alive() + process = None + + def deploy(version: str, stateless_http: bool) -> str: + nonlocal process + stop() + new_process = multiprocessing.get_context("spawn").Process( + target=_run_mcp_deployment, + args=(port, version, stateless_http), + ) + new_process.start() + process = new_process + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", port)) == 0: + return f"http://127.0.0.1:{port}/mcp" + if new_process.exitcode is not None: + raise RuntimeError( + f"MCP {version} deployment exited with {new_process.exitcode}" + ) + time.sleep(0.05) + raise TimeoutError(f"MCP {version} deployment did not start") + + yield deploy + stop() class EmptyMCPClient: def __init__(self) -> None: self.tools: list[MCPToolDefinition] = [] self._tools_reconciled_callback: Any = None + self.closed = False def sync_close(self) -> None: - pass + self.closed = True class RecordingMCPToolProvider: @@ -100,3 +220,122 @@ 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() + + +def test_refresh_discovers_tools_from_an_initially_empty_client( + tmp_path: Path, monkeypatch +) -> None: + client = EmptyMCPClient() + 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, + mcp_tool_provider=RecordingMCPToolProvider(client), + ) + conversation._ensure_agent_ready() + discovered = MCPToolDefinition.create( + mcp_tool=mcp_types.Tool( + name="discovered", + description="discovered after deployment", + inputSchema={"type": "object", "properties": {}}, + ), + mcp_client=cast(MCPClient, client), + )[0] + client.tools = [discovered] + + def refresh(client, timeout, *, on_tools_reconciled): + on_tools_reconciled(client, client.tools) + + monkeypatch.setattr( + "openhands.sdk.conversation.impl.local_conversation._refresh_mcp_client_tools", + refresh, + ) + + conversation.refresh_mcp_tools() + + assert set(conversation.agent.tools_map) == {"discovered"} + conversation.close() + + +def test_initialization_failure_closes_an_empty_mcp_client( + tmp_path: Path, monkeypatch +) -> None: + client = EmptyMCPClient() + 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=tmp_path, + visualizer=None, + mcp_tool_provider=RecordingMCPToolProvider(client), + ) + + def fail_to_add_tools(_self, _tools): + raise RuntimeError("failed to add runtime tools") + + monkeypatch.setattr(Agent, "add_runtime_tools", fail_to_add_tools) + + with pytest.raises(RuntimeError, match="failed to add runtime tools"): + conversation._ensure_agent_ready() + + assert client.closed + assert conversation._mcp_clients == [] + + +@pytest.mark.parametrize("stateless_http", [False, True], ids=["stateful", "stateless"]) +def test_refresh_reconnects_after_mcp_deployment( + tmp_path: Path, + deploy_mcp_server: Callable[[str, bool], str], + stateless_http: bool, +) -> None: + url = deploy_mcp_server("old", stateless_http) + llm = cast( + ToolRecordingLLM, + ToolRecordingLLM.from_messages( + [Message(role="assistant", content=[TextContent(text="Done")])] + ), + ) + conversation = LocalConversation( + agent=Agent( + llm=llm, + tools=[], + include_default_tools=[], + mcp_config=coerce_mcp_config( + {"analysis": {"transport": "http", "url": url}} + ), + ), + workspace=tmp_path, + visualizer=None, + ) + + try: + conversation.send_message("Inspect the deployed tools") + assert set(conversation.agent.tools_map) == {"changing", "old_tool"} + conversation_id = conversation.id + events = list(conversation.state.events) + workspace = conversation.workspace + + deploy_mcp_server("new", stateless_http) + conversation.refresh_mcp_tools() + + assert conversation.id == conversation_id + assert list(conversation.state.events) == events + assert conversation.workspace is workspace + assert set(conversation.agent.tools_map) == {"changing", "new_tool"} + changing = conversation.agent.tools_map["changing"] + assert changing.description == "New schema." + changing.action_from_arguments({"new": 7}) + + conversation.run() + + assert llm._tool_snapshots == [["changing", "new_tool"]] + finally: + conversation.close() diff --git a/tests/sdk/conversation/test_local_conversation_plugins.py b/tests/sdk/conversation/test_local_conversation_plugins.py index 4413eea924..79d336666e 100644 --- a/tests/sdk/conversation/test_local_conversation_plugins.py +++ b/tests/sdk/conversation/test_local_conversation_plugins.py @@ -31,6 +31,10 @@ class EmptyMCPClient: def __init__(self): self.tools = [] + self.closed = False + + def sync_close(self) -> None: + self.closed = True class RecordingMCPToolProvider: @@ -796,6 +800,9 @@ def __init__(self): self.tools = [runtime_tool] self._tools_reconciled_callback: Any = None + def sync_close(self) -> None: + pass + marketplace_dir = create_test_marketplace( tmp_path / "marketplace", plugins=[ @@ -846,6 +853,55 @@ def __init__(self): conversation.close() + def test_load_plugin_failure_closes_an_empty_mcp_client( + self, tmp_path: Path, mock_llm + ): + marketplace_dir = create_test_marketplace( + tmp_path / "marketplace", + plugins=[ + { + "name": "mcp-plugin", + "mcp_config": { + "mcpServers": {"runtime-server": {"command": "runtime"}} + }, + } + ], + ) + workspace = tmp_path / "workspace" + workspace.mkdir() + client = EmptyMCPClient() + conversation = LocalConversation( + agent=Agent( + llm=mock_llm, + tools=[], + agent_context=AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="manual", source=str(marketplace_dir) + ) + ] + ), + ), + workspace=workspace, + visualizer=None, + mcp_tool_provider=RecordingMCPToolProvider([], client), + ) + conversation._ensure_agent_ready() + + with ( + patch.object( + Agent, + "add_runtime_tools", + side_effect=RuntimeError("failed to add runtime tools"), + ), + pytest.raises(RuntimeError, match="failed to add runtime tools"), + ): + conversation.load_plugin("mcp-plugin") + + assert client.closed + assert conversation._mcp_clients == [] + conversation.close() + def test_load_plugin_merges_runtime_hooks_and_restarts_processor( self, tmp_path: Path, mock_llm ): diff --git a/tests/sdk/mcp/test_mcp_tool_list_changed.py b/tests/sdk/mcp/test_mcp_tool_list_changed.py index fa4e57d1f9..14362143bb 100644 --- a/tests/sdk/mcp/test_mcp_tool_list_changed.py +++ b/tests/sdk/mcp/test_mcp_tool_list_changed.py @@ -75,22 +75,29 @@ def _make_mcp_tool(name: str) -> mcp_types.Tool: class _FakeClient: - """Minimal stand-in for ``MCPClient`` used by ``_refresh_tools``. - - ``_refresh_tools`` only needs ``list_tools()`` (async) and the - ``_tools`` / ``_closed`` attributes, so a lightweight fake keeps the diff - logic unit-testable without spinning up a real server. - """ + """Minimal stand-in for focused tool-list reconciliation tests.""" def __init__(self, tools: list[mcp_types.Tool]): self._server_tools = list(tools) self._tools: list[MCPToolDefinition] = [] self._closed = False + self._tools_refresh_lock = asyncio.Lock() self._tools_reconciled_callback = None + self._connected = True + + def is_connected(self) -> bool: + return self._connected + + async def connect(self) -> None: + self._connected = True async def list_tools(self) -> list[mcp_types.Tool]: return list(self._server_tools) + @property + def tools(self) -> list[MCPToolDefinition]: + return list(self._tools) + class _ConcreteAgent(AgentBase): """Minimal concrete ``AgentBase`` for unit-testing runtime helpers.