fix(mcp): keep the MCP connection on its own task (0.16.7.post2, for release/s201) - #1095
Conversation
Backport of #1087 onto the mcp 1.26 client. The HTTP client, transport and ClientSession live in one AsyncExitStack whose anyio cancel scopes have to be exited by the task that entered them, LIFO. Callers can't do that: langgraph opens the connection inside a tool task while dispose() runs on the teardown task, and an agent with several servers closes them oldest-first. Result was "Attempted to exit a cancel scope that isn't the current task's current cancel scope", swallowed on the way out and coming back as a CancelledError that failed a job which had already produced the right answer. Now a dedicated task opens the whole stack and holds it until dispose() signals it to close (or cancels it mid-handshake), so enter and exit happen on the same task and order stops mattering. Recovery still re-runs the handshake on the same session. Bumps to 0.16.7.post2 (hotfix on exactly what release/s201 ships; nothing else moves). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
alessandromuresan
left a comment
There was a problem hiding this comment.
Checked this as a port rather than re-reviewing the design — I reviewed #1093 and the findings there are all in this tree. No changes requested.
The three source files are byte-identical to what merged in #1093, which as of 16:52 is what release/uipath-langchain-0.16.16 carries:
| file | vs #1093 head 46094a5 |
vs release/uipath-langchain-0.16.16 |
|---|---|---|
mcp_client.py |
identical | identical |
claude.md |
identical | identical |
test_mcp_client_task_ownership.py |
identical | identical |
So the review fixes from #1093 are all present and didn't need re-deriving: _client_initialized = False in the connection task's finally, _ensure_session raising instead of handing out a missing session, _session = None in _close_connection before the if task is None return, close_requested passed in rather than asserted off self, the try/finally in dispose(), the four claude.md lifecycle spots, and the fourth test (test_a_connection_that_dies_on_its_own_is_rebuilt_on_the_next_call) plus the ASGI recorder behind the DELETE assertion.
The "applies as-is" claim holds — the relevant base files are byte-identical between the two release branches, not just mcp_client.py:
src/uipath_langchain/agent/tools/mcp/mcp_client.py— identical onrelease/uipath-langchain-0.16.7andrelease/uipath-langchain-0.16.16src/uipath_langchain/agent/tools/mcp/streamable_http.py— identical. This one matters beyond diff arithmetic: the poison-the-client path that #1093'sfinallyfix addresses comes from the transport being an@asynccontextmanageryielding out ofasync with anyio.create_task_group()withtg.start_soon(...)children (streamable_http.py:723-748). Same code here, so the fix is load-bearing for the same reason and the new regression test covers the same scenario.src/uipath_langchain/agent/tools/mcp/mcp_tool.py— identical, so nothing else in the package touches the changed internals.tests/agent/tools/test_mcp/test_mcp_client.py:524-525has the same_session is None/_stack is Noneassertions, so retaining theself._stackassignment keeps the purpose it was kept for in #1093.
claude.md is the only one whose bases differ, and only because #1093 already landed on the newer branch — 0.16.7's copy is the pre-fix text, and this PR brings it to the same post-fix content.
Packaging, which is the only real delta from #1093:
0.16.7.post1→0.16.7.post2;deepagentscorrectly left at>=0.5.9, <0.6.0andmcp==1.26.0untouched, which is the whole point of the second artifact.- Publish-on-merge works:
cd.ymlon this branch triggers onpushtorelease/**filtered topaths: [pyproject.toml], and this PR touchespyproject.toml. #1065 is the precedent for a.postrelease off this same branch. - The
uv.lock[options] exclude-newerrewrite to the0001-01-01T00:00:00Zsentinel looked like stray churn from a neweruv, so I checked before flagging it:mainandrelease/uipath-langchain-0.16.16already carry that sentinel and 0.16.7 was the stale one,exclude-newer-span = "P2D"is present and unchanged, and uv's own inline comment says it has no effect. Convergence, not drift — no action.
CI is green on all 13 checks including the full 3.11/3.12/3.13 × ubuntu/windows test matrix. One incidental note that is inert here but worth knowing if this fixture gets reused: the test's import uvicorn is satisfied transitively through mcp (uv.lock:2361, under mcp==1.26.0) rather than by a declared dev dependency. On this branch mcp is pinned exactly, so it can't move; on main it's a >= range and a future mcp that drops uvicorn would break the import rather than skip the test.
Leaving this as a comment, not an approval — the branch shows REVIEW_REQUIRED and I'd rather the gate on a ring-1 artifact stay with you.
5ba45fe
into
release/uipath-langchain-0.16.7



Same fix as #1093, on exactly what
uipath-agents release/s201(ring 1) ships today:uipath-langchain 0.16.7.post1. Bumps to 0.16.7.post2 so CD publishes on merge — the same shape as0.16.7.post1(#1065), a code hotfix published from this branch. (PRODEV-1583)Why a second artifact: #1093 lands as 0.16.20 on
release/uipath-langchain-0.16.16, which since #1092 requiresdeepagents>=0.7.11.release/s202can take that — #744 moved it todeepagents 0.7.x/ 0.16.19 today — butrelease/s201still pinsdeepagents==0.5.9and its code importsBackendFactory, gone in 0.7.x, so 0.16.19+ is not resolvable there.mcp_client.pyis byte-identical between 0.16.7 and 0.16.18, so the fix applies as-is; thedeepagentsconstraint here stays>=0.5.9,<0.6.0.Agents with 2+ MCP sessions open finish the run correctly and then fail at teardown with
ERROR_CancelledError, so the job shows up as Faulted. The HTTP client, transport andClientSessionsit in oneAsyncExitStack; its anyio cancel scopes must be exited by the task that entered them, LIFO. Callers can't guarantee either: langgraph'sToolNodeopens the connection inside a tool task whiledispose()runs on the teardown task, and an agent with several servers disposes them oldest-first. The scope error was caught underexcept Exceptionand logged at DEBUG, and the half-closed scope resurfaced later as aCancelledError.A dedicated task now opens the whole stack and holds it until
dispose()signals it to close, or cancels it if the handshake hasn't finished. The task'sfinallyalso clears_client_initialized, so a connection that dies on its own is rebuilt by the next call instead of poisoning the client, and_ensure_sessionraises rather than handing out a missing session. Setup errors still surface as themselves. Recovery is unchanged (re-runs the handshake on the same session). Includes the #1093 review fixes.Tests, against a real
FastMCP1.x server that records what reached it, all asserting nothing was swallowed at DEBUG:DELETEreaches the serverCancelledErrortests/agent/tools/test_mcpgreen (76);ruffandmypyclean;agent/tools/mcp/claude.mdupdated.Verified on the s201 stack itself —
uipath-agents release/s201(factory as-is, creation-order dispose) with this build installed with its dependencies:deepagents 0.5.9anduipath-langchain-client 1.17.3unchanged; 3 Dynamic servers with one tool call and 2 Cached servers with both/one called from tool tasks all clean, 0 swallowed.uv lock --dry-runonrelease/s201withuipath-langchain[bedrock,vertex]==0.16.7.post2changes exactly one package.🤖 Generated with Claude Code