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
74 changes: 49 additions & 25 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,
provider_supports_on_tools_reconciled,
)
from openhands.sdk.observability.laminar import (
Expand Down Expand Up @@ -308,6 +309,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 @@ -1281,19 +1283,19 @@ 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
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
Expand All @@ -1306,7 +1308,8 @@ def _runtime_mcp_tools(
client = self._mcp_tool_provider.create_tools(
mcp_config, _RUNTIME_MCP_TIMEOUT_SECS, **create_kwargs
)
return list(client.tools)
self._mcp_clients.append(client)
return client

def _on_mcp_tools_reconciled(
self,
Expand All @@ -1315,10 +1318,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 @@ -1337,18 +1340,29 @@ 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:
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
errors: list[Exception] = []
for client in tuple(self._mcp_clients):
Comment thread
Shimada666 marked this conversation as resolved.
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,
_refresh_mcp_client_tools(
client,
_RUNTIME_MCP_TIMEOUT_SECS,
on_tools_reconciled=self._on_mcp_tools_reconciled,
)
except Exception as exc:
errors.append(exc)
if errors:
raise ExceptionGroup("Failed to refresh MCP tools", errors)

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 @@ -1390,14 +1404,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 @@ -1423,14 +1437,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 @@ -1488,19 +1505,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 @@ -2666,6 +2687,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
13 changes: 13 additions & 0 deletions openhands-sdk/openhands/sdk/mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Callable, Iterator, Sequence
from typing import TYPE_CHECKING, Any

import httpx
from fastmcp import Client as AsyncMCPClient

from openhands.sdk.mcp.exceptions import MCPError
Expand Down Expand Up @@ -41,13 +42,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 +65,16 @@ 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")
try:
await self.__aexit__(None, None, None)
except httpx.TransportError:
self._reset_session_state(full=True)
await self.connect()

def call_async_from_sync(
self,
awaitable_or_fn: Callable[..., Any] | Any,
Expand Down
Loading
Loading