modelcontextprotocol / modelcontextprotocol/python-sdk

OAuthClientProvider auth lock is permanently poisoned when httpx closes async_auth_flow from a different task (RuntimeError: The current task is not holding this lock)

Đang mở Phù hợp với người mới
#3,382 4 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

v1 v2
Ngôn ngữ chính
Python
Star
24.3k
Fork
4k
Merge trung bình
1 ngày 1 giờ
Pull request đã merge (30 ngày)
31

Mô tả

Summary

OAuthClientProvider.async_auth_flow holds self.context.lock (an anyio.Lock) across the entire httpx auth-flow generator, including every yield (src/mcp/client/auth/oauth2.py:582 in v2.0.0; same code is present on v2.1.0 and main). anyio.Lock.release() is bound to the acquiring task. When httpx closes the auth generator from a different task than the one that advanced it — which happens routinely when a request is cancelled mid-flight (network drop, timeout, task-group teardown) — the async with __aexit__ runs in the closing task and raises:

RuntimeError: The current task is not holding this lock

The exception escapes into a fire-and-forget teardown task ("Task exception was never retrieved"), and the lock is left permanently held. Every subsequent request through the same OAuthClientProvider then blocks forever at async with self.context.lock: without sending any HTTP. For a long-lived client that reuses the provider across reconnects, that server is dead until the whole process restarts.

Environment

  • mcp 2.0.0 (reproduced; the same async with self.context.lock: pattern is unchanged in 2.1.0 and on main)
  • Python 3.11.15, macOS (darwin), anyio 4.14.2, asyncio backend
  • Long-lived agent process (Nous Research Hermes) with OAuth-backed streamable-HTTP MCP servers; surfaced downstream as NousResearch/hermes-agent#81051

Observed traceback

Two captures from the same host, one per yield point (authorization-code exchange and the main yield request):

ERROR asyncio: Task exception was never retrieved
future: <Task finished name='Task-598' coro=<<async_generator_athrow without __name__>()> exception=RuntimeError('The current task is not holding this lock')>
Traceback (most recent call last):
  File ".../site-packages/mcp/client/auth/oauth2.py", line 754, in async_auth_flow
    yield request
GeneratorExit

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../site-packages/mcp/client/auth/oauth2.py", line 582, in async_auth_flow
    async with self.context.lock:
  File ".../site-packages/anyio/_core/_synchronization.py", line 173, in __aexit__
    self.release()
  File ".../site-packages/anyio/_backends/_asyncio.py", line 1935, in release
    raise RuntimeError("The current task is not holding this lock")
RuntimeError: The current task is not holding this lock

(The other capture is identical except the GeneratorExit lands at line 746, token_response = yield await self._perform_authorization().)

After this fires, every reconnect attempt for that server times out with no HTTP traffic — the flow generator never gets past line 582.

Minimal reproduction (no network needed)

import asyncio
import httpx2
from mcp.client.auth.oauth2 import OAuthClientProvider
from mcp.shared.auth import OAuthClientMetadata


class MemStorage:
    async def get_tokens(self): return None
    async def set_tokens(self, tokens): pass
    async def get_client_info(self): return None
    async def set_client_info(self, client_info): pass


async def main():
    provider = OAuthClientProvider(
        server_url="https://example.invalid/mcp",
        client_metadata=OAuthClientMetadata(redirect_uris=["http://localhost:1/callback"]),
        storage=MemStorage(),
    )

    # Task A: advance the flow. With no stored tokens it suspends at
    # `response = yield request`, still inside `async with context.lock`.
    flow = provider.async_auth_flow(httpx2.Request("POST", "https://example.invalid/mcp"))
    await flow.asend(None)

    # Task B: httpx tears the stream down from a different task on
    # cancellation, closing the generator there.
    try:
        await asyncio.get_running_loop().create_task(flow.aclose())
    except RuntimeError as exc:
        print(f"aclose raised: {exc!r}")            # <- fires

    print("lock still held:", provider.context.lock.locked())   # True

    # The "reconnect" — blocks forever at line 582:
    flow2 = provider.async_auth_flow(httpx2.Request("POST", "https://example.invalid/mcp"))
    try:
        await asyncio.wait_for(flow2.asend(None), timeout=2)
        print("second flow proceeds")
    except asyncio.TimeoutError:
        print("second flow BLOCKED on poisoned lock")           # <- happens
    finally:
        await flow2.aclose()

asyncio.run(main())

Output on 2.0.0:

aclose raised: RuntimeError('The current task is not holding this lock')
lock still held: True
second flow BLOCKED on poisoned lock

Root cause

anyio.Lock is task-bound by design; httpx makes no guarantee that the auth-flow generator is closed from the task that advanced it (cancellation/teardown commonly runs aclose() from a sibling task). Holding a task-bound lock across the generator's yields therefore poisons the lock on any cross-task close: the release both raises and never happens.

Suggested fix

Serializing the auth flow per-context is still needed (token refresh must not race), but the primitive must allow release from the closing task. Options:

  1. Use anyio.Semaphore(1) instead of anyio.Lock for OAuthContext.lock. anyio semaphores are not owner-bound, so release from the closing task is legal on both asyncio and trio backends. One-line change in the OAuthContext dataclass plus the async with keeps working.
  2. Alternatively, wrap the generator body so GeneratorExit/cross-task unwind releases via a tolerant path (catch the ownership RuntimeError and force the lock back to a released state), though anyio has no public API for that today.

Happy to send a PR for option 1 if that direction is acceptable.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu trong src/mcp/client/auth/oauth2.py tại OAuthContext và OAuthClientProvider.async_auth_flow, sau đó chạy bản tái hiện tối thiểu từ issue. Xác minh rằng việc đóng flow từ một task khác không gây ra lỗi hoặc để context ở trạng thái bị khóa, và rằng flow tiếp theo vẫn tiếp tục thay vì bị block.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
api, authentication
Loại issue
Lỗi
Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Đặc tả rõ ràng
Mức phù hợp với người mới
82/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.