fix(mcp): production JSON-RPC routing with sync-safe teardown (from #1169) - #1175
Conversation
Replacement for #1169: enable use_production_mode for MCPToolClient, use HTTP /mcp sessions for connect/list_tools/call_tool, and put session/httpx cleanup on _close_async so SyncEnvClient.close and sync context exit do not leak MCP sessions. Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co>
Bugbot correctly flagged that HTTP-only production connect broke reset/step/state. Open WebSocket for the Gym path and still create the HTTP MCP session for list_tools/call_tool; sync close teardown unchanged. Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co>
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5951e3a. Configure here.
| """ | ||
| if getattr(self, "use_production_mode", False): | ||
| try: | ||
| await super()._connect_async() |
There was a problem hiding this comment.
Connect opens two independent sessions
High Severity
Production connect() now opens /ws and then creates a separate HTTP MCP session. Both call _create_session(), so they consume two capacity slots and bind Gym methods and tool calls to different environment instances. With the default max_concurrent_envs=1, the second create fails and connect() cannot complete.
Reviewed by Cursor Bugbot for commit 5951e3a. Configure here.
There was a problem hiding this comment.
Stale comment
Alignment Review Report
Two-tier review of the production JSON-RPC routing + sync-safe teardown fix. Verified against a clean checkout (base
08af2d2f==origin/mainHEAD, no drift; two-dot == three-dot).Automated Checks
- Lint: PASS —
ruff format --check,ruff check src/ tests/, andusort checkare all clean on both changed files.lint.shexits 1 only on ~57 pre-existingenvs/**/README.mdreformat drift (neither changed file appears; CI lint scope issrc/+tests/).- Debug code: CLEAN —
check-debug.shreports only pre-existingsrc/openenv/cli/**console.print/TODO hits; nothing inmcp_client.py.- Tests: PASS —
tests/core/= 613 passed / 2 skipped.test_mode_selection.py(39) +test_production_mode_routes.py= 92 passed / 1 skipped. No regressions.Context (verified)
- Fixes #1168 (on-repo replacement for #1169):
MCPToolClient(mode="production")leftuse_production_mode = False(hardcoded dead code since #351), solist_tools/call_toolfell back to WSstep()instead of HTTP/mcpJSON-RPC. Setting it fromself._mode == "production"activates the pre-existing production path. Since_modeis normalized+validated to"production"for all MCP clients, the flag is effectively alwaysTrue.- Server side matches:
POST /mcp(http_server.py:1455) →mcp_handlerimplementsopenenv/session/create|close+tools/list|call("persistent MCP lifecycles over HTTP /mcp"), so the client's activated path resolves against real endpoints.- Sync-safe teardown is correct:
SyncEnvClient.close()/__exit__/_dispatchall invoke_close_asyncdirectly (sync_client.py:227), never aclose()override — so the previous code (overridingclose()) leaked the server-side MCP session on sync teardown. Moving cleanup to_close_async+await super()._close_async()is exactly the right fix, covered bytest_production_mode_sync_close_closes_mcp_session.Open RFCs Context
- RFC 003 — MCP Support (In Review; @Darktex, @pankit-eng) governs this area. The change supports RFC 003 (routes agent tool traffic through MCP JSON-RPC). No conflict.
Tier 1: Fixes Required
None. The core change is correct and all changed lines pass lint/format/import/type checks.
Non-blocking correctness/robustness (verified by repro)
MCPToolClient.reset()/step()/state()now raise a bareAssertionErrorin production mode. The new_connect_asynccreates an HTTP MCP session but never opens a WebSocket, so_send/_state_async'sassert self._ws is not Nonefails (reproduced — all three raiseAssertionError). Two consequences:
- Against a live server,
reset()/step()first create a server-side MCP session (openenv/session/create) and then assert-fail, leaking that session.- The
MCPToolClientclass docstring still advertisesawait env.reset()andawait env.step(CallToolAction(...))(~L407-427), which now break.Suggestion (non-blocking): either raise a clear
RuntimeError("reset/step/state unavailable in production MCP mode; uselist_tools()/call_tool()") or open the WS lazily for those, and update the docstring examples. This is arguably by-design (production bypasses the Gym step-loop), so it's a robustness/docs polish, not a merge blocker.Tier 2: Alignment Discussion
Principle Conflicts
None. The fix strengthens "minimize lifecycle deltas / production-readiness" (production mode now actually uses the production transport) and is consistent with the Dual-API-boundary invariant (Gym-style
reset/step/stateare infra controls and are now unavailable to the production MCP client).mcp_client.pyimports only framework/shared modules — client-server separation intact.RFC / Invariant Awareness (non-blocking)
ALIGNMENT FLAG: Production MCP tool traffic routed over HTTP
/mcpwhile the invariants steer toward WebSocket-only
- Invariant/RFC at stake: INVARIANTS.md §4 "Communication patterns" ("WebSocket for all environment communication"; "deprecating HTTP … in favor of WebSocket-only, still transitioning") + RFC 003 (In Review).
- The concern: This activates the HTTP
/mcptransport for productionlist_tools/call_tool. The server also serves MCP JSON-RPC over WebSocket (@app.websocket("/mcp")andtype:"mcp"frames on/ws), so a path aligned with the WebSocket-only direction already exists. The change matches the issue's stated intent (HTTP JSON-RPC) and pre-existing design, so it's likely the right near-term fix — but it deepens reliance on HTTP in an area the invariants flag as in-transition. Worth a team decision on whether production MCP should ultimately be MCP-over-WebSocket.- Suggested reviewer: @Darktex (authored the Communication-patterns invariant
b1a92e1b1and the production MCP code in #351; RFC 003 co-author) + @pankit-eng (RFC 003); loop inswappyfor the persistent MCP session behavior.Summary
- 0 mechanical (Tier 1) issues to fix
- 1 verified non-blocking robustness/docs issue (production
reset/step/state→AssertionError+ stale docstring)- 1 Tier-2 alignment awareness (HTTP vs WebSocket-only transport direction); 0 RFC conflicts (RFC 003 supported)
Sent by Cursor Automation: Pre-review
There was a problem hiding this comment.
Holding merge despite exact-head CI green on 5951e3a3 (cannot REQUEST_CHANGES on our own PR).
Bugbot’s dual-session finding is correct and blocking for default servers (max_concurrent_envs=1):
/wsconnect already calls_create_session()- production
_ensure_production_session()then calls HTTPopenenv/session/create→ a second_create_session() - that second create hits capacity and
connect()cannot complete; even when capacity allows, Gym methods and tool calls bind to different env instances
The client never learns the WebSocket session id, so HTTP tools cannot reuse the WS session today. HTTP-only connect (prior head) avoids the capacity clash but breaks reset/step/state.
This is the same architectural hole #1158 tried to close. Do not land the one-line use_production_mode flip (or this dual-connect approach) until production mode has a single shared session for Gym + tools. Prefer closing #1169/#1175 as incomplete unless we explicitly choose an HTTP-only tools-only mode with clear errors for Gym APIs and updated docs.
Sent by Cursor Automation: Release
There was a problem hiding this comment.
Alignment Review Report
Scope: 2 commits since main (e045fc6d, 5951e3a3); 2 files — src/openenv/core/mcp_client.py (+28 net) and tests/core/test_mode_selection.py (mostly new tests). This is a genuine core-client bug fix for #1168 (the on-repo replacement for #1169).
Update since the first commit: the HTTP-only-connect regression flagged earlier (production
reset/step/stateraising a bareAssertionError) is resolved by5951e3a3, which reopens the WebSocket for the Gym lifecycle. Verified against the current head and covered bytest_production_mode_connect_opens_websocket_and_http_session.
Automated Checks
- Lint: PASS —
usort check,ruff format --check, andruff checkall pass on both changed files (ran ruff 0.16.7 / usort locally;.claude/hooks/lint.shcan't run on this review VM becauseuvisn't installed). No repo-wide lint regression attributable to this PR. - Debug code: CLEAN —
check-debug.shreports only pre-existingprint(...)examples inside docstrings; this PR adds no new prints, breakpoints, or TODO/FIXME.
Open RFCs Context
- RFC 003 — MCP Support (In Review) — directly relevant and supported. Its Transport section (amended Nov 15, 2025) endorses
POST /mcpJSON-RPC for production/inference (Scenario 3), which is exactly what this PR activates. RFC 003 lists "No session management" as an early limitation, but the server already shipsopenenv/session/create|closeas an intentional OpenEnv extension ("persistent MCP lifecycles over HTTP /mcp"), so that note reads as stale rather than a live conflict. - RFC 000/001/002 (In Review) — Dual-API boundary + "WebSocket for step loop." The second commit keeps reset/step/state on
/ws, so the Gym control plane stays on WebSocket. - RFC 010 / 011 (Draft) — unrelated (ECHO world-model; ARD catalog discovery).
Tier 1: Fixes Required
-
src/openenv/core/mcp_client.py:14-30— (low severity, non-blocking) module docstring is now slightly inaccurate. Withuse_production_modedefaulting toTrueand the WebSocket reopened, productionMCPToolClientuses both transports:/wsforreset/step/stateand/mcpforlist_tools/call_tool. The architecture diagram ("Production Mode … /mcp … Bypasses step()") and the "Client Usage" lines (MCPToolClient (default) → /ws,(production) → /mcp) don't reflect this dual-transport behavior, and imply a non-productionMCPToolClientthe constructor disallows. Suggest a quick update.
No lint, syntax, import, type, or security issues found.
Tier 2: Alignment Discussion
Principle Conflicts
None blocking. The change respects the dual-API boundary and "agents cannot reset": reset/step/state stay on /ws; only agent tool traffic uses /mcp. Client-server separation is intact (no server/ imports). The close → _close_async rename is the correct fix for sync-safe teardown — sync paths (SyncEnvClient.close, sync __exit__, _dispatch) invoke _close_async directly, so the old public-close override was skipped on sync teardown and leaked the server-side MCP session; now covered by test_production_mode_sync_close_closes_mcp_session.
ALIGNMENT FLAG (non-blocking; confirm direction): production tool traffic defaults to HTTP /mcp while the repo is transitioning off HTTP
- Principle/Invariant at stake: INVARIANTS.md §4 "Communication patterns" — "WebSocket for all environment communication … deprecating HTTP … in favor of WebSocket-only (transitioning, both available)."
- The concern: this PR makes HTTP
/mcpthe live default for productionlist_tools/call_tool. RFC 003 explicitly sanctionsPOST /mcp, so it's aligned near-term — but a server-side MCP-over-WebSocket path already exists (@app.websocket("/mcp")andtype:"mcp"JSON-RPC frames on/ws). Worth confirming whether production MCP should ultimately converge on MCP-over-WebSocket to match the "WebSocket-only" direction, and reconciling the wording between INVARIANTS §4 and RFC 003. - Suggested reviewer: @Darktex (authored the production MCP code in #351, INVARIANTS §4, and co-authors RFC 003)
RFC Conflicts
None. RFC 003 is supported, not conflicted. Optionally @pankit-eng (RFC 003 co-author) can confirm the HTTP MCP session lifecycle semantics.
Summary
- 1 mechanical issue to fix (module docstring accuracy; low severity, non-blocking)
- 1 alignment point for human review (HTTP-vs-WebSocket transport direction)
- 0 RFC conflicts to discuss (RFC 003 supported)
Overall: mechanically sound, well-tested, and the earlier reset/step/state regression is resolved. No blocking issues.
Sent by Cursor Automation: Pre-review
| ) | ||
| self._tools_cache: Optional[List[Tool]] = None | ||
| self.use_production_mode = False | ||
| self.use_production_mode = self._mode == "production" |
There was a problem hiding this comment.
Core fix (Tier 1). This is the crux of #1168: use_production_mode was hardcoded to False, so the entire HTTP /mcp production path (_production_mcp_request, _ensure_production_session, the production branches in list_tools/call_tool) was dead code. Since MCPClientBase only permits mode='production', this flag is now effectively always True for MCPToolClient — correct.
Minor doc nit: this makes the module docstring (lines 14–30) stale — production now uses both /ws (reset/step/state) and /mcp (list_tools/call_tool), but the architecture diagram / "Client Usage" section implies /mcp-only and a non-production MCPToolClient that can't be constructed. Worth a quick update.
| await super()._connect_async() | ||
| await self._ensure_production_session() |
There was a problem hiding this comment.
Regression fix confirmed. Reopening the WebSocket (super()._connect_async()) before creating the HTTP MCP session correctly resolves the earlier HTTP-only-connect issue where reset/step/state raised a bare AssertionError (assert self._ws is not None) in production. The failure path (await self.close() → _close_async) is safe in both async and sync-loop contexts (close() dispatches to an awaitable _close_async). Well covered by test_production_mode_connect_opens_websocket_and_http_session and test_production_mode_connect_failure_cleans_up_resources.
Tier 2 (non-blocking): activating HTTP /mcp for production tool calls sits in mild tension with INVARIANTS.md §4 ("WebSocket for all environment communication … deprecating HTTP in favor of WebSocket-only"). It's sanctioned by RFC 003 and a server-side MCP-over-WS path already exists, so this is a direction to confirm with @Darktex / @pankit-eng — not a blocker.
| ) | ||
|
|
||
| async def close(self) -> None: | ||
| async def _close_async(self) -> None: |
There was a problem hiding this comment.
Sync-safe teardown crux. Overriding _close_async rather than the public close is the right call: SyncEnvClient.close, sync __exit__, and _dispatch all invoke _close_async directly, so the previous close() override was skipped on sync teardown and leaked the server-side MCP session (via openenv/session/close + httpx cleanup). Async paths still reach this through close() → _dispatch(_close_async). Good regression coverage in test_production_mode_sync_close_closes_mcp_session.
* fix: clear 0.4.3 release blockers Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * style: format socket close scheduler Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * fix(discovery): fail closed on relative home cache Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * fix(client): preserve parent teardown on child cancellation Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * fix(discovery): reject Unicode line separators Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * fix(client): close every child despite cancellation Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * Revert "fix(mcp): production JSON-RPC routing with sync-safe teardown (from #1169) (#1175)" This reverts commit e3eb3fa. * fix(mcp): preserve sync-safe teardown after revert Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * style(mcp): document best-effort HTTP cleanup Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * fix(mcp): isolate explicit HTTP tools mode Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> * chore: separate shared MCP follow-up Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co>




Summary
On-repo replacement for #1169 (mugenkyou). Routes
MCPToolClientproduction mode through HTTP/mcpJSON-RPC instead of WebSocketstep(), and fixes the sync-close session leak Bugbot flagged on the fork head.Changes
use_production_modefromself._mode == "production"connect()creates a persistent HTTP MCP session (no WebSocket)list_tools()/call_tool()usetools/list/tools/callover JSON-RPChttpxcleanup to_close_asyncsoSyncEnvClient.close, sync__exit__, and_dispatchall tear down the MCP sessionopenenv/session/closeCloses #1168 (via replacement for #1169).
Type of Change
Alignment Checklist
Before submitting, verify:
.claude/docs/PRINCIPLES.mdand this PR aligns with our principles.claude/docs/INVARIANTS.mdand no invariants are violatedRFC Status
Test Plan
PYTHONPATH=src:envs uv run pytest tests/core/test_mode_selection.py -q— 39 passedClaude Code Review
N/A — release automation replacement for fork #1169.
Note
Medium Risk
Changes client connection/teardown and production tool transport paths used by default
MCPToolClient; incorrect behavior could leak MCP sessions or break tool calls, though scope is limited to MCP client lifecycle.Overview
Fixes production MCP routing and session teardown for
MCPToolClientby tyinguse_production_modeto client mode and hooking lifecycle at the async connection/close hooks the sync wrapper already uses.Production
connect()now opens the WebSocket (forreset/step/state) and creates a persistent HTTP/mcpsession forlist_tools/call_toolover JSON-RPC (tools/list,tools/call), withclose()invoked if session setup fails. MCP session andhttpxcleanup moved from overridingclose()to_close_async()so syncclose(), context managers, and_dispatchstill callopenenv/session/close.Tests replace WebSocket
stepexpectations with JSON-RPC mocks and add coverage for connect (WS + session), connect failure cleanup, and sync close tearing down the MCP session.Reviewed by Cursor Bugbot for commit 5951e3a. Bugbot is set up for automated code reviews on this repo. Configure here.