fix: close stranded MCP OAuth auth flows - #76526
Open
armsteadj1 wants to merge 2 commits into
Open
Conversation
Collaborator
teknium1
reviewed
Aug 2, 2026
teknium1
left a comment
Contributor
There was a problem hiding this comment.
Thanks for addressing a real MCP OAuth teardown failure. Current main still constructs the delegated SDK generator at tools/mcp_oauth_manager.py:419 without closing it when the wrapper is abandoned; the added finally at PR tools/mcp_oauth_manager.py:492-495 directly covers that gap.
Problems
tests/tools/test_mcp_oauth_generator_cleanup.py:271-276relies on GC and fixed sleeps to observe finalization. That is timing-dependent rather than a deterministic assertion of the HTTPX teardown path.
Suggested changes
- Replace the GC/sleep case with an HTTPX forced-timeout plus provider-reuse regression. Related PR #63495 contains that deterministic shape while preserving the same core
inner.aclose()fix. - Keep the direct AnyIO private-owner recovery at
tools/mcp_oauth_manager.py:100-104tightly scoped and behavior-tested because it relies on_owner_task.
Automated hermes-sweeper review.
| previous_handler = loop.get_exception_handler() | ||
| loop.set_exception_handler(lambda _loop, context: handled.append(context)) | ||
| try: | ||
| del flow |
Contributor
There was a problem hiding this comment.
This GC/fixed-sleep finalizer check is timing-dependent. Please replace it with a deterministic HTTPX timeout teardown and provider-reuse test, as in related PR #63495, so the regression does not depend on scheduler timing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Close the inner MCP SDK OAuth auth-flow generator when Hermes' wrapper is abandoned, cancelled, or timed out by HTTPX — and reclaim a stranded
anyio.Lockwhen the wrapper itself is finalized from a different task.This is the comprehensive fix for #38193 and the same class of failure reported in #49543 and #31987. It incorporates the strongest parts of both prior PRs (#38198 by @igorhvr, #63495 by @NaMinhyeok) while adding cross-task finalizer safety that neither covers.
Root cause
HermesMCPOAuthProvider.async_auth_flowbridges httpx's bidirectional auth-flow protocol onto the SDK's generator. The SDK acquires ananyio.Lock(self.context.lock) at the top of its flow and holds it across everyyield. When httpx closes our wrapper on transport failure, cancellation, or timeout, the inner SDK generator was simply dropped — still suspended insideasync with self.context.lock.Two consequences, both confirmed independently by multiple reporters (Granola, Databricks, Swiggy Instamart, Honeycomb, Reportify, gbrain):
aclose()on the orphaned inner generator from a different task. anyio's task-boundLock.release()raisesRuntimeError("The current task is not holding this lock"), and_owner_taskis never cleared.context.lockstays permanently held. Every subsequent auth flow for that server blocks forever — a successful OAuth callback ("Authorization Successful") never produces tokens,hermes mcp loginwedges, andhermes mcp testreports no cached tokens.What this PR does
Production fix (
tools/mcp_oauth_manager.py):finally: await inner.aclose()— closes the inner SDK generator from the owning task, so the lock's__aexit__runs in the correct task and the release succeeds. (Same core mechanism as fix: Close inner SDK auth generator to prevent OAuth reconnect deadlock #38198/fix(mcp-oauth): close inner SDK auth generator on teardown (salvage #38198) #63495.)_close_inner_auth_flow— wraps the above with exception handling for the cross-task case: when our own wrapper is GC-finalized off-task, theinner.aclose()itself hits the task-ownership RuntimeError. We swallow it (preventing the unretrieved-task-exception console noise) and fall through to the reclaim._reclaim_stranded_auth_lock— belt-and-braces: ifinner.aclose()couldn't release the lock (cross-task finalization), reclaim it by identity-checking_owner_taskagainst the recorded prior owner. Only steals the lock when it's owned by the exact dead flow task; a lock held by a genuinely concurrent flow is never touched. Hands the lock to any queued waiter via the normalrelease()path.Regression tests — organized so reviewers immediately see each scenario:
test_httpx_timeout_closes_inner_flow_and_releases_locktest_mcp_oauth_bidirectional.pyhttpx.AsyncClient(auth=provider)withMockTransport, forcedReadTimeout, lock released, provider reused with 200test_aclose_mid_flow_releases_the_sdk_auth_locktest_mcp_oauth_generator_cleanup.pyaclose()releases locktest_flow_after_abandoned_login_does_not_wedgetest_mcp_oauth_generator_cleanup.pytest_cross_task_cleanup_is_silent_and_still_frees_the_locktest_mcp_oauth_generator_cleanup.pytest_garbage_collected_flow_emits_no_unhandled_task_exceptiontest_mcp_oauth_generator_cleanup.pytest_normal_completion_still_ends_with_stop_async_iterationtest_mcp_oauth_generator_cleanup.pytest_repeated_flows_reuse_the_locktest_mcp_oauth_generator_cleanup.pytest_reclaim_ignores_a_lock_held_by_a_different_flowtest_mcp_oauth_generator_cleanup.pytest_reclaim_is_a_noop_without_a_recorded_ownertest_mcp_oauth_generator_cleanup.pytest_reclaim_hands_the_lock_to_a_waiting_flowtest_mcp_oauth_generator_cleanup.pyRelated issues and PRs
finally: await inner.aclose()mechanism)How this compares to the other PRs
finally: await inner.aclose()_reclaim_stranded_auth_lockType of Change
How to Test
Run the focused OAuth suite:
Run the full MCP OAuth test surface:
scripts/run_tests.sh tests/tools/test_mcp_oauth*.py tests/tools/test_mcp_tool_401_handling.py tests/tools/test_mcp_reconnect_signal.py -qStatic checks:
Checklist
Code
Documentation & Housekeeping
cli-config.yaml.exampleupdate — N/A