modelcontextprotocol / modelcontextprotocol/python-sdk

Bug: OAuth provider holds context.lock for the whole request, so the standalone GET SSE stream stalls the first tools/call by ~15s

Offen
#3,209 1 Kommentar 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

P2 v1 v2
Vorherrschende Sprache
Python
Sterne
24.3k
Forks
4k
Ø Merge
1 T. 1 Std.
Gemergte PRs (30 T.)
31

Beschreibung

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() - t0

Why it happens

  1. async_auth_flow takes context.lock and holds it for the whole lifetime of the request, not just while it obtains a token — the async with self.context.lock: at src/mcp/client/auth/oauth2.py:492 wraps response = yield request.
  2. After initialized, streamablehttp_client opens the long-lived standalone GET SSE stream.
  3. 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.
  4. The first tools/call therefore waits on the lock for as long as the GET stream lives.
  5. 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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Erste Schritte

  1. Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
  3. Forke das Repository und arbeite in einem Branch.
  4. Öffne einen Pull Request, der die Issue-Nummer nennt.

Rechercherichtung

Beginne mit src/mcp/client/auth/oauth2.py ungefähr bei Zeile 492 und dem Einstiegspunkt streamablehttp_client. Vergleiche anschließend die vorgeschlagene Behebung in #2858. Reproduziere den eigenständigen GET-SSE-Ablauf mit dem bereitgestellten Skript und überprüfe, dass die Anfrage nicht mehr auf die OAuth-Kontext-Sperre wartet und dass der erste tools/call die Verzögerung durch die Stream-Lebensdauer vermeidet.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
python
Bereich
api, authentication
Issue-Typ
Bug
Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Aktivitätsstatus
Veraltet
Klarheit
Klar beschrieben
Anfängerfreundlichkeit
25/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.