Repository navigation
Bug: OAuth provider holds context.lock for the whole request, so the standalone GET SSE stream stalls the first tools/call by ~15s #3209
Description
Summary
With auth=OAuthClientProvider on a streamable-HTTP connection, the first tools/call of every session blocks for ~15 s. No error, no warning — just latency. Nothing is concurrent here: one server, one client, one session, sequential calls.
Root cause is the same context.lock-held-across-yield problem as #2847 / #2644, but the symptom is different enough that it seems worth recording separately: #2847 is about RuntimeError: The current task is not holding this lock under concurrent OAuth connections, while this is a silent latency tax paid by every OAuth streamable-HTTP user, concurrency or not. Both are fixed by #2858.
Environment
mcp==1.29.0,anyio==4.13.0,httpx==0.28.1- Python 3.14.6, macOS arm64
- Server: FastMCP behind Google Cloud Run, streamable HTTP, OAuth 2.1 + PKCE
Measurement
Same server, same tool, one fresh session per call, three calls per arm. The only variable is how the bearer token reaches the request:
auth=OAuthClientProvider : 15.68s / 15.74s / 15.80s
static Authorization header: 0.70s / 0.62s / 0.67s
Raw HTTP JSON-RPC against the same endpoint (no SDK) answers the same tool in ~0.07 s, so the useful work is ~100 ms and effectively all of the 15.7 s is the wait.
Repro:
import time, anyio
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def one(*, use_provider: bool) -> float:
headers = None if use_provider else {"Authorization": f"Bearer {ACCESS_TOKEN}"}
auth = build_provider() if use_provider else None # OAuthClientProvider
t0 = time.perf_counter()
async with streamablehttp_client(URL, headers=headers, auth=auth) as (r, w, _):
async with ClientSession(r, w) as session:
await session.initialize()
await session.call_tool("get_last_modified", {})
return time.perf_counter() - t0Why it happens
async_auth_flowtakescontext.lockand holds it for the whole lifetime of the request, not just while it obtains a token — theasync with self.context.lock:atsrc/mcp/client/auth/oauth2.py:492wrapsresponse = yield request.- After
initialized,streamablehttp_clientopens the long-lived standalone GET SSE stream. - That GET goes through
async_auth_flow, takes the lock, and does not release it until the stream ends — 15–60 s in our logs, dictated by the server's/proxy's idle timeout, not by the client. - The first
tools/calltherefore waits on the lock for as long as the GET stream lives. - Once the stream closes, subsequent calls run at ~100 ms until the next stream is opened.
So the stall is not a fixed 15 s: it is "however long the server keeps an idle SSE stream open", which for Cloud Run happens to be ~15 s and for other deployments in this thread ~60 s.
The fix
#2858 (rebase of #2660 by @peisuke) already does the right thing: no HTTP yield — including the standalone GET SSE long-poll — runs while context.lock is held. Verified locally that with the lock out of the request path the same call is ~0.4–0.7 s.
Our workaround in the meantime is to keep OAuthClientProvider out of the request path entirely: read the token from storage and pass it as a plain Authorization header, refreshing it ourselves. That is a fine escape hatch for an application, but it means giving up the SDK's auth handling, which most users reasonably will not want to do.
Would a maintainer be able to look at #2858? It fixes a correctness bug for concurrent users and a ~15 s per-session latency bug for everyone else.
Activity
Ben-Home commentedon Jul 29, 2026
Excellent diagnosis. The
context.lock-held-across-yield pattern is subtle because it looks fine in a synchronous flow — the lock protects token acquisition, which intuitively "should" cover the whole authenticated request lifecycle. But with SSE long-poll streams, that lifecycle extends from milliseconds to tens of seconds, and every subsequent call in the session queues behind it.The measurement data is the smoking gun: 15.7s with OAuthClientProvider vs 0.67s with a static header — and both should produce identical authenticated requests. That ~15x difference is entirely lock contention.
A few additional observations:
1. This affects every OAuth deployment, not just Cloud Run. The stall duration is dictated by the server's idle timeout on the SSE stream, not the client. Cloud Run's ~15s idle timeout gives you a 15s stall; nginx's default
proxy_read_timeout 60sgives a 60s stall; Cloudflare's 100s default gives a 100s stall. The longer the timeout, the worse the first-call latency.2. Workaround while waiting for #2858: token pre-issuance. If your deployment pipeline can issue an access token before starting the MCP session (e.g., during service startup or via a sidecar that handles OAuth), pass it as a static
Authorizationheader instead of usingOAuthClientProvider. This bypasses the lock entirely because the token is already available — noasync_auth_flowcall needed. We do this at corpusiq.io for our 36-connector MCP server: tokens are pre-issued during session initialization via a separate OAuth flow, and the MCP transport layer receives them as headers. Firsttools/calllatency drops from 15s to under 100ms.3. The fix in #2858 is the right approach — releasing the lock before the HTTP
yield— but make sure it also handles token refresh mid-session correctly. The SSE stream can span multiple token lifetimes; if the lock is released during the GET, a concurrenttools/callthat triggers a token refresh needs to coordinate with the stream's in-flight request that's using the old token. The currentasync with self.context.lock:pattern prevents this race by brute force. Releasing the lock during yields creates a window where two requests can both see an expired token and both attempt refresh.This is a real-world performance regression that hits every OAuth-protected MCP deployment. Glad to see #2858 tracking it.
Ecocitizenz commentedon Jul 31, 2026