Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions openhands-sdk/openhands/sdk/conversation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 45 additions & 27 deletions openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -1411,14 +1419,17 @@ 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(),
]
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:
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions openhands-sdk/openhands/sdk/mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ 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):
super().__init__(*args, **kwargs)
self._executor = AsyncExecutor()
self._closed = False
self._tools = []
self._tools_refresh_lock = asyncio.Lock()
self._tools_reconciled_callback = None

@property
Expand All @@ -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,
Expand Down
66 changes: 52 additions & 14 deletions openhands-sdk/openhands/sdk/mcp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions tests/cross/test_remote_conversation_live_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading