Skip to content

feat(mcp): refresh tools for active conversations - #4402

Open
Shimada666 wants to merge 4 commits into
OpenHands:mainfrom
Shimada666:feat/conversation-mcp-refresh
Open

feat(mcp): refresh tools for active conversations#4402
Shimada666 wants to merge 4 commits into
OpenHands:mainfrom
Shimada666:feat/conversation-mcp-refresh

Conversation

@Shimada666

@Shimada666 Shimada666 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

HUMAN:
We expose backend-owned MCP tools to OpenHands App Server running in E2B. As these tools may change between releases, existing conversations need a lightweight way to refresh their tool definitions without being forked or recreated.


AGENT:

Why

notifications/tools/list_changed handles changes while an MCP server remains available, but an existing conversation also needs an explicit refresh after the backend tool schema changes across a deployment. The conversation, history, and workspace must remain intact.

Summary

  • Add Conversation.refresh_mcp_tools() for local and remote conversations, plus an Agent Server REST action.
  • Refresh a healthy stateless MCP connection directly; reconnect only when the session is terminated or already disconnected.
  • Reuse the authoritative add/update/remove reconciliation from fix(mcp): close reconciliation gaps left by #4367 #4369 and track initially empty MCP clients.
  • Attempt every tracked MCP client and report all failures with ExceptionGroup.

Issue Number

N/A

How to Test

The real FastMCP deployment test runs in stateful and stateless HTTP modes. It replaces a server process on the same URL, changes tool names and schemas, and verifies that the same conversation receives the new snapshot. It also verifies that stateless refresh performs no new initialization and that a refresh can recover after the server was temporarily unavailable.

Commands and current results:

uv run pytest -q tests/sdk/conversation/test_local_conversation_mcp.py tests/sdk/mcp
144 passed

uv run pre-commit run --files openhands-sdk/openhands/sdk/mcp/client.py openhands-sdk/openhands/sdk/mcp/utils.py openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py tests/sdk/conversation/test_local_conversation_mcp.py
All hooks passed, including Ruff, pyright, import dependency rules, and tool registration checks.

Video/Screenshots

N/A — SDK and Agent Server API change with no visual surface.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

@Shimada666
Shimada666 marked this pull request as ready for review August 6, 2026 16:38
@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

1 similar comment
@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@Shimada666

Shimada666 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @neubig and @VascoSch92 — thank you both for the work and review around #4367 and #4369.

This PR follows the same line of work. #4367 handles add/update/remove reconciliation while an MCP connection remains alive, and #4369 hardens that path. This PR covers a separate deployment boundary: when the MCP server process is replaced, an existing conversation can explicitly reconnect and refresh its tool snapshot. It reuses the reconciliation path introduced in #4367 and preserves the conversation rather than forking or recreating it.

CI is green now. When you have a chance, I’d really appreciate your thoughts on whether this is the right lifecycle and API boundary. Thank you!

@Shimada666

Copy link
Copy Markdown
Contributor Author

Hi @neubig — when you have a chance, could you please take a look at this PR? We have a production integration that needs to refresh MCP tool definitions for existing conversations after backend deployments, so this capability would be very helpful to us. All CI checks are passing, and the change is independent of #4369. If the interface and lifecycle look right to you, we’d really appreciate your help reviewing and merging it. Thank you!

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Shimada666 just a couple of quick questions, given that this PR goes quite deep in the core code: I wonder if you have seen that MCP 2.0 is now stateless, so all this careful handling of sessions, I think, shouldn’t be necessary anymore?

Also, is this a problem you encounter a lot or sometimes or rarely?

Just trying to understand what we could do here. 🤔

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: b31b15302e345e09df0dc3973d25fadcd45113f2
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/fbfc7c2d-9c0b-4215-a995-33f79261d8d6

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds Conversation.refresh_mcp_tools() for both local and remote conversations, with an Agent Server REST endpoint (POST /{conversation_id}/refresh_mcp_tools). It reconnects each configured MCP client and reuses the existing add/update/remove reconciliation from #4367. The lock migration from _ToolListChangedHandler._refresh_lock to MCPClient._tools_refresh_lock is a good improvement — it ensures the notification-driven refresh and the explicit refresh share the same lock, preventing concurrent refresh attempts on the same client.

The test coverage is thorough: the deployment test (test_refresh_reconnects_after_mcp_deployment) exercises the real scenario (process termination + restart on the same URL) in both stateful and stateless HTTP modes, verifies tool add/remove/update semantics, confirms conversation ID/events/workspace are preserved, and asserts the next LLM completion receives exactly the refreshed tool set.

Risk Assessment

Low–Medium. The change is additive and follows existing patterns. The REST endpoint is additive with correct 404 handling. The run_in_executor wrapper in EventService.refresh_mcp_tools is consistent with the existing load_plugin path. The main concern is error propagation in the refresh loop (see inline comment), which could leave a multi-server conversation partially refreshed.

Findings

1. Error propagation aborts multi-server refresh (Moderate)

In LocalConversation.refresh_mcp_tools(), if one MCP client fails to reconnect (e.g., one server is still down while others are healthy), the exception propagates immediately and remaining clients are skipped. For conversations with multiple MCP servers, one dead server blocks refreshing all others. The close() method already handles individual client failures gracefully with best-effort cleanup. Consider wrapping each client's refresh in a try/except, logging failures, and continuing — or at minimum, documenting that callers should expect partial refresh on failure.

2. _reconnect partial-failure state (Low, non-blocking)

In MCPClient._reconnect, if __aexit__ succeeds but connect() fails (e.g., the new server process hasn't fully started), the client has no active session but _closed remains False. This is handled gracefully by the existing MCPToolExecutor.call_tool reconnection logic (which checks is_connected() and retries), so it's not a bug — just noting that the client remains in a recoverable-but-disconnected state.

No Issues Found

  • The unconditional on_tools_reconciled call in _reconnect_and_refresh_tools (line 193-194) is correct — it ensures the agent re-processes the full tool snapshot after a session replacement, even if the tool list is identical. _refresh_tools is called without callbacks in the reconnect path, so there's no double-callback risk.
  • fastmcp.Client.__aexit__ uses anyio.move_on_after(self._disconnect_timeout) internally, so calling __aexit__ on a dead session won't hang.
  • The _close_mcp_client error-path cleanup correctly removes the client from _mcp_clients.
  • Thread safety is preserved: tuple(self._mcp_clients) is used for safe iteration during refresh, and all list mutations occur on sync code paths between runs.

Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py Outdated
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 461e038a8142550897329ebb32a3cd57c0040572
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/89294626-fbfa-4a44-a8ef-270f9915ebaf

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds Conversation.refresh_mcp_tools() for local and remote conversations, backed by an Agent Server REST endpoint (POST /{conversation_id}/refresh_mcp_tools). The approach—reconnect each MCP client and reuse the existing add/update/remove reconciliation from #4367—is sound and well-scoped. Tracking MCP clients on the conversation (even when their initial tool list is empty) is a good improvement that ensures later deployments can add tools and cleanup remains complete.

The deployment test (test_refreshes_tools_after_mcp_deployment) is thorough: it exercises process termination + restart in both stateful and stateless HTTP modes, verifies tool add/remove/update semantics, confirms conversation ID/events/workspace are preserved, and asserts the next LLM completion receives exactly the refreshed tool set.

Risk Assessment

Medium. The change is additive and follows existing patterns. The REST endpoint has correct 404 handling and the run_in_executor wrapper in EventService is consistent with load_plugin. However, the reconnect logic in _refresh_or_reconnect_tools has a narrow exception catch (except McpError) that does not cover all dead-session failure modes, which can leave a client permanently stuck after a failed refresh. See the inline findings below for details.

Findings

1. Client gets permanently stuck after a failed refresh (Moderate)

_refresh_or_reconnect_tools only catches McpError to trigger reconnection. But when a dead session is accessed, list_tools() can raise exceptions that are not McpError:

  • ConnectError (httpx transport error): raised when the session still appears connected but the underlying HTTP connection is dead (e.g., the server was terminated and no replacement is running yet).
  • RuntimeError: raised by the session property ("Client is not connected...") when is_connected() is False — which is the state the client ends up in after a failed ConnectError.

I verified this empirically by killing an MCP server process and calling _refresh_or_reconnect_tools:

# Server killed, new server NOT yet running:
_refresh_tools raised: ConnectError: All connection attempts failed
  isinstance McpError? False

# After the ConnectError, is_connected() becomes False.
# New server started, refresh retried:
Refresh with 3rd server raised: RuntimeError: Client is not connected.
  isinstance McpError? False

After the initial ConnectError, is_connected() flips to False. Subsequent refresh attempts raise RuntimeError (not McpError), so the except McpError catch never triggers, and the client is permanently stuck — even when the server comes back up, refresh_mcp_tools() cannot recover it.

This is inconsistent with both the notification-driven path and the tool-execution path, which both check is_connected() and reconnect proactively:

  • _refresh_connected_tools (line 181): if not client.is_connected(): await client.connect()
  • MCPToolExecutor.call_tool (tool.py line 80): if not self.client.is_connected(): ... await self.client.connect()

Suggested fix: Add an is_connected() check at the start of _refresh_or_reconnect_tools (mirroring _refresh_connected_tools), or broaden the catch to include RuntimeError:

if not client.is_connected():
    await client._reconnect()
    await _refresh_tools(client)
else:
    try:
        await _refresh_tools(client)
    except McpError:
        await client._reconnect()
        await _refresh_tools(client)

The primary use case (kill old → start new → refresh) works correctly because the new server is already running when list_tools() is called, producing a McpError: Session terminated that IS caught. The bug manifests when the refresh is called while the server is briefly unavailable — a realistic operational scenario during rolling deployments.

2. Error propagation aborts multi-server refresh (Low–Moderate)

In LocalConversation.refresh_mcp_tools(), if one MCP client fails to refresh (e.g., ConnectError, RuntimeError, or MCPError after a failed reconnect), the exception propagates immediately and remaining clients in self._mcp_clients are never refreshed. For conversations with multiple MCP servers, one dead server blocks refreshing all others.

The close() method (line 2665) handles individual client failures gracefully with best-effort cleanup. Consider wrapping each client's refresh in a try/except, logging failures, and continuing to the next client so healthy servers still get refreshed — or at minimum, documenting that callers should expect partial refresh on failure.

No Issues Found

  • The lock migration from _ToolListChangedHandler._refresh_lock to MCPClient._tools_refresh_lock is correct — it ensures the notification-driven refresh and the explicit refresh share the same lock.
  • The unconditional on_tools_reconciled call in _refresh_or_reconnect_tools (line 199-200) is correct — _refresh_tools is called without callbacks in the reconnect path, so there's no double-callback risk.
  • _close_mcp_client correctly removes the client from _mcp_clients and sync_close() is safe to call (handles exceptions internally).
  • Thread safety is preserved: tuple(self._mcp_clients) is used for safe iteration, and the agent's _on_mcp_tools_reconciled uses self._tools_lock.
  • The close() method's MCP client cleanup is correct and idempotent.

Comment thread openhands-sdk/openhands/sdk/mcp/utils.py Outdated
Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
Shimada666 and others added 3 commits August 10, 2026 17:19
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@Shimada666
Shimada666 force-pushed the feat/conversation-mcp-refresh branch from 461e038 to 4f8a647 Compare August 10, 2026 09:24
Co-authored-by: openhands <openhands@all-hands.dev>
@Shimada666

Shimada666 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @enyst — yes, our production integration uses stateless MCP. I changed the implementation so a healthy client performs a direct tools/list refresh without reconnecting; the real stateless FastMCP deployment test now asserts that the replacement server receives zero new initialize calls. The reconnect path remains only as compatibility and recovery for terminated or disconnected sessions.

This is not frequent per request, but it occurs deterministically when our backend-owned MCP tool schema changes while a long-lived Analysis conversation is still active. We call the explicit action after such a deployment so the existing conversation can use the new schema without being recreated. I also rebased onto the now-merged #4369 and reused its reconciliation path.

Please let me know if you would prefer a narrower approach or if there is anything else I should adjust. Thanks again for taking a look!

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 5cb2a35ea558134cb9de2578ea4e8f4a015e99db
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/802eb4a6-c9a4-4009-8e88-d154c30a9da0

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Collaborator

⚠️ OpenHands PR Reviewer encountered a problem at commit 5cb2a35ea558 (status: stuck).

This comment was posted by an AI agent (OpenHands).

Copy link
Copy Markdown
Collaborator

🔍 Review in progress…

We are performing the review through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 5cb2a35ea558134cb9de2578ea4e8f4a015e99db
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/f81b26ac-434b-426d-abf5-cdad5520b4c2

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds Conversation.refresh_mcp_tools() (local + remote) and a matching agent-server REST action to re-fetch MCP tool definitions for an active conversation without forking or recreating it. The design is sound: a shared _tools_refresh_lock serializes the notification-driven and explicit refresh paths; _refresh_or_reconnect_tools reconnects only on a terminated session (McpError / not is_connected()) and otherwise reuses the live session; failures across multiple clients are aggregated into an ExceptionGroup; and MCPClient.sync_close() is idempotent so the new close() client loop plus the existing tool-executor loop don't double-close.

I reviewed the reconnect/reconcile control flow, lock ordering between the asyncio refresh lock and the agent's threading RLock, the _close_runtime_tools to _close_mcp_client rename (no stale references remain), the REST endpoint 404 handling, and the event-service thread-pool delegation. The test matrix (real FastMCP deployments in stateful + stateless modes, recovery after temporary unavailability, initially-empty clients, multi-client failure aggregation, plugin load/close paths) covers the important scenarios.

Findings

No material bugs, security problems, or significant design flaws found.

A couple of minor, non-blocking observations (no change required):

  • _refresh_or_reconnect_tools calls on_tools_reconciled(client, client.tools) unconditionally, even when the tool snapshot is unchanged, whereas the notification path only fires it when there is an actual add/update/remove. The reconciliation is idempotent so this is harmless, just slightly redundant.
  • MCPClient._reconnect only catches httpx.TransportError from __aexit__; a non-transport exception would propagate and leave the client disconnected (though _disconnect suppresses most internal errors, so this is very unlikely in practice). A subsequent refresh would still recover via the not is_connected() path.

Risk Assessment

Low risk. The change is additive (new refresh_mcp_tools API surface) and the existing notification-driven reconciliation path is preserved. The renamed _runtime_mcp_tools to _runtime_mcp_client refactoring is mechanical and well-covered by tests. The new REST endpoint is guarded by the existing conversation lookup and returns 404 for unknown conversations.

@Shimada666

Copy link
Copy Markdown
Contributor Author

Hi @enyst @VascoSch92, gentle ping for another look when you have a chance. The concerns raised in the earlier reviews have been addressed, and the latest automated review found no material issues and assessed the change as low risk. Would either of you be able to review this for approval? Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants