split MCP OAuth teardown ownership from MCP 2.0 transport composition - #84963
split MCP OAuth teardown ownership from MCP 2.0 transport composition#84963andrexibiza wants to merge 23 commits into
Conversation
An OAuth MCP server could park with a bare `TimeoutError` and stay unusable for the life of the process, while its endpoint and tokens were both healthy. `get_or_build_provider()` caches one `OAuthClientProvider` per server and only discards it when `server_url` changes. That provider holds two asyncio primitives bound to the loop that first awaited them: the entry's own `lock`, and the SDK's `context.lock`, which `mcp/client/auth/oauth2.py` holds across the whole auth flow (including its `yield`s). MCP runs on a dedicated loop created by `_ensure_mcp_loop()`. `shutdown_mcp_servers()` stops that loop and clears `_servers` on every MCP reload, but never touches the OAuth cache — and its inner shutdown is bounded by `future.result(timeout=15)`, whose failure is only logged at debug before `_stop_mcp_loop()` runs regardless. An auth flow still in flight at that moment is stranded holding the lock, on a loop that will never run again. The next connect reuses that provider, so `session.initialize()` blocks on a lock no live loop can release. It never reaches the wire: the only symptom is the empty `TimeoutError` from the `asyncio.wait_for` in the streamable-HTTP connect path, which reads as a network fault and sends you looking at egress or credentials. Nothing recovers it in-process — the parked self-probe rebuilds the transport but reuses the same poisoned provider — so the server stays down until restart. Observed in the wild as two multi-hour outages across separate PIDs, ~19h and ~18h, against a reachable endpoint with valid tokens. Record the binding loop on `_ProviderEntry` and treat a stopped or closed one like a URL change: drop the entry and rebuild. Tokens live on disk, so a rebuild reloads them and cannot trigger re-auth — `_build_provider` only raises `OAuthNonInteractiveError` when no cached tokens exist. Deliberately narrow: only a stopped/closed loop invalidates. A different-but-live loop is fine, since an uncontended lock rebinds on next acquire, and an unrecorded loop (sync CLI paths) fails open — churning providers would discard live OAuth state for no reason. Affects OAuth HTTP servers only; stdio servers have no provider and were never impacted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Close delegated SDK auth generators deterministically across completion, failure, cancellation, and outer close; fence provider reuse across construction-config and event-loop ownership changes. Preserve response-driven OAuth semantics and callback/redirect configuration. Co-authored-by: THArrowofApollo <144398232+THArrowofApollo@users.noreply.github.com>
Co-authored-by: THArrowofApollo <tharrowofapollo@users.noreply.github.com>
Co-authored-by: THArrowofApollo <tharrowofapollo@users.noreply.github.com>
Co-authored-by: THArrowofApollo <tharrowofapollo@users.noreply.github.com>
Co-authored-by: THArrowofApollo <tharrowofapollo@users.noreply.github.com>
Co-authored-by: THArrowofApollo <tharrowofapollo@users.noreply.github.com>
Co-authored-by: THArrowofApollo <tharrowofapollo@users.noreply.github.com>
The stop-aware rework of _paste_callback_reader made the POSIX branch
poll stdin with select.select, which raises TypeError for a wrapper
without a native descriptor (e.g. a unittest.mock stdin in tests).
The reader then returned without reading, so a completed `skip`
never landed and _wait_for_callback misreported it as a timeout
instead of raising OAuthNonInteractiveError("user_skipped") — the
exact "skip misreported as timeout" class this series set out to
close.
Mirror the Windows branch's mock handling: when stdin has no native
fd, fall back to a direct readline for mocked/in-memory streams
while keeping the select-polling loop for real descriptors.
Signed-off-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
CI repair — slice 3/12
|
|
Current architecture decision after reconciling this draft with MCP 2.0 and #88875: Do not rebase all 23 commits monolithically. Split the surviving ownership fix from the transport-era adaptation, preserve @THArrowofApollo provenance on both, and restack the transport half on the current MCP 2.0 negotiation surface. Why:
Recommended split:
Topology recommendation: keep this PR draft while extracting the lifecycle half; once both child PRs exist, close this draft as credited split/superseded-by-children, not as obsolete or duplicate. Do not merge this 23-commit branch directly into current main. |
andrexibiza
left a comment
There was a problem hiding this comment.
Split execution has started on current main f43eabee5f36e11448086ee8ee17c499958e81bf: created fork branch fix/mcp-oauth-lifecycle-current-main from that exact upstream SHA. Current-main audit shows durable token invalidation has independently landed, while callback first-writer arbitration and delegated-flow owner/generation fencing have not. The lifecycle child therefore requires a residual-aware extraction, not a wholesale replay of the 23-commit branch or replacement of current mcp_oauth.py / mcp_oauth_manager.py with historical blobs. Transport-era composition remains a separate child after the lifecycle residual is isolated. @THArrowofApollo credit remains mandatory on derived publication commits.
andrexibiza
left a comment
There was a problem hiding this comment.
Credited split publication receipt: lifecycle/callback child is now live as #90888 (fix/mcp-oauth-lifecycle-current-main), based on current main f43eabee5f36e11448086ee8ee17c499958e81bf, ready-for-review (draft:false). Initial exact commits: 7bc1e456d1e534ff0465da7fc9fa7c29408c99ef (owner-task delegated-flow close) and b0377b4024c9f3ac4d0864cb424174a99623ca3b (lifecycle witnesses), both carrying Co-authored-by: THArrowofApollo <igorhvr@iasylum.net>. The lifecycle child remains the place to stack callback arbitration and generation fencing. The second child remains transport/control-plane composition only and will restack on current MCP 2.0 negotiation; do not replay this parent monolithically.
|
Current-main transport-child audit receipt (base The surviving transport/control-plane residual is narrower: A current-main branch now exists for that child: |
|
Topology correction from current source-of-truth verification: the advertised #90888 remains the first credited current-main child. This parent must stay open until a real second child exists for the remaining MCP 2.0 / HTTPX transport-composition residual and carries exact-head verification. Do not infer completion from the historical 23-commit train or from the empty branch. The current-main transport already has a response-hook redirect guard ( Preserve @THArrowofApollo provenance while restacking the residual. I am leaving #84963 open intentionally until both children are real and independently verified. |
Preserve the credited NousResearch#84963 lifecycle extraction and the cleanup-failure precedence hardening while composing onto current main b2c4f1f. Refs NousResearch#38193. Preserves @THArrowofApollo / igorhvr provenance from the retained branch history.
Parent-retirement gate published in #91913The new authority-continuity manifest records this PR as non-retirable while the MCP 2.0 transport/control-plane child is absent. The executable retirement rule is now semantic exhaustiveness, not merely “two child PRs exist.” Before closure, every surviving parent invariant must be assigned to exactly one disposition:
The manifest also locks the negative topology: protocol-era discovery and OAuth metadata discovery remain orthogonal, and the second child may not introduce another era selector, transport factory, provider cache, or credential authority. Contract PR: #91913 |
Current architecture disposition
Do not merge or monolithically rebase this 23-commit PR. Split it into two credited children, then close this PR as superseded by the credited split once both child PRs exist.
tools/mcp_oauth.py,tools/mcp_oauth_manager.py, and their focused tests. Preserve deterministic delegated-generator close on the owner task, provider generation fencing, failed-close retry, atomic first-writer-wins callback arbitration, lock-consistent waiter snapshots, durable token cleanup after failed refresh, and the real teardown/callback witnesses.mcp_tool.pyand HTTPX/OAuth wire integration on the current negotiation architecture after WIP(mcp): dual-era hardening — bounded -32602 proof probe pending #88875. Prove legacy Streamable HTTP, current stateless Streamable HTTP, SSE where supported, and both Hermes-served MCP surfaces. Do not introduce a second era selector, transport factory, or cache identity.server/discover/initializeselects protocol era; PRM/ASM/CIMD select OAuth control-plane metadata. Failure in one axis must not silently select the other. CIMD feeds the same validated OAuth metadata model; it is not a bypass or second provider cache.Co-authored-bycredit and the substantial teardown/callback evidence in the lifecycle child and in any transport child that carries code derived from this branch.This PR therefore Refs #38193. It must not auto-close the issue before the lifecycle child lands and is verified on current main.
Related: #49543, #81051, #88875.
What this PR proved
This branch built a credible closure candidate for the MCP OAuth teardown-lock failure class across the then-current public HTTP transport surfaces:
A → B → Aredirect cannot restore credentials;Those lifecycle and callback invariants remain valuable. The transport integration is no longer source-of-truth because current main and #88875 have since changed protocol negotiation, MRTR, listen supervision, stateless liveness, cache identity, and real dual-SDK wire coverage.
Why
The SDK OAuth flow holds an AnyIO lock across async-generator yields. If Hermes closes only its outer bridge after transport teardown, the inner generator can be finalized from another task, fail AnyIO's ownership check, and leave the cached provider permanently locked. Long-lived HTTP streams also require explicit separation between OAuth control traffic and resource data-plane bearer headers.
The published predecessor exposed one additional loaded-CI race: callback timeout polling could beat an already-started non-interactive skip reader. Adversarial review then found that HTTP and paste writers—and later the waiter read—needed one shared arbitration boundary. The final object atomically publishes and snapshots terminal callback state while preserving deterministic thread and listener cleanup.
Attribution
This is the credited successor to the original patch by @THArrowofApollo. The publication range contains exactly one canonical
Co-authored-bytrailer preserving that authorship and no newly introducedSigned-off-bytrailer. Any child extraction must preserve that credit rather than treating this PR as obsolete or duplicate.Historical verification evidence
Exact historical candidate:
9721dab3fb2b2dc0bb000abbb55498b85db72b09Tree:
3ebdff9c6e679d358fa84077436b662203c0220cSynthetic verification against then-live
main8018f9e016a934fe68174ddfa6e7e735956a4c54:e35e2a8179177748997d5e06bcbb04511d91efd3;8b36ee007d1b204b200318776167dac425d2e9450b48c69146b89e8726b6bce0;py_compile, attribution audit, andgit diff --checkpassed.Fresh exact-candidate verification also passed:
Two independent exact-object reviewers approved the same historical SHA/tree after verifying shared-lock coverage for every callback result writer and waiter decision, candidate-tree closure, publication-range attribution, transport composition, OAuth provenance, and irreversible
A → B → Aredirect taint.The later POSIX skip-token regression was repaired at current PR head
6ab18eb3c7f26b3c01df4b68b01b2bf3d3b4bb38and its focused OAuth suites passed. None of this old-head evidence substitutes for current-main verification of either child.Type of change represented
Historical files
tools/mcp_oauth.pytools/mcp_oauth_manager.pytools/mcp_tool.py