Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
from mcp.shared._request_clock import waiting_on_a_person
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
Expand Down Expand Up @@ -425,10 +426,9 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
auth_params["prompt"] = "consent"

authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
await self.context.redirect_handler(authorization_url)

# Wait for callback
result = await self.context.callback_handler()
with waiting_on_a_person():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing: Users who open a subscription or hit a tool re-list while a login is pending still lose the call to their read timeout after merging. waiting_on_a_person() at src/mcp/client/auth/oauth2.py:429 only pauses the clock send_raw_request installs; two read_timeout_seconds bounds sit outside it as plain anyio.fail_after. Fix: every read-timeout bound around a request that can be challenged must be a pausable clock, e.g. pass the timeout into the request's opts["timeout"] and drop the outer fail_after, which covers the 2 sites listed. Same pattern at 2 sites (src/mcp/client/subscriptions.py:271, src/mcp/client/client.py:833). [also at: src/mcp/client/auth/oauth2.py:431 - pre-existing: A user whose first request after connecting is listen() still gets a TimeoutError when the login outlasts read_timeout_seconds.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A client built with Client(transport, mode="2026-07-28", read_timeout_seconds=30) and an OAuthClientProvider whose token is missing or expired opens a subscription as its first call. drive() in src/mcp/client/subscriptions.py:250 sends the listen request with no opts["timeout"], so the clock from src/mcp/shared/jsonrpc_dispatcher.py:408 is infinite and the pause at src/mcp/client/auth/oauth2.py:429 changes nothing. The subscriber task meanwhile waits in anyio.fail_after(session._session_read_timeout_seconds) at src/mcp/client/subscriptions.py:271 for the ack; that scope is a plain deadline the clock cannot move, so a login longer than 30 s raises TimeoutError out of the subscribe context manager. The same holds at src/mcp/client/client.py:833, where anyio.fail_after(timeout) wraps _relist_tool and a 401 on the re-list ends in raise mismatch from relist_error. The base branch fails both the same way; the PR's description says login time is no longer counted, which holds only for the clock inside send_raw_request.

Verification: The pause at src/mcp/client/auth/oauth2.py:429 only touches the RequestClock installed at src/mcp/shared/jsonrpc_dispatcher.py:408. src/mcp/client/subscriptions.py:271-272 waits in a plain anyio.fail_after that keeps counting while the login blocks the POST. src/mcp/client/client.py:833 wraps _relist_tool in anyio.fail_after(timeout). Both files are untouched by the PR, so the base fails identically.

await self.context.redirect_handler(authorization_url)
result = await self.context.callback_handler()
Comment on lines +429 to +431

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Users who set read_timeout_seconds to bound an OAuth login now get a call that never returns, and no doc page says so. src/mcp/client/auth/oauth2.py:429 pauses the request clock for the whole redirect_handler/callback_handler wait, so a callback that never completes is no longer ended by the request timeout. Fix: update the OAuth client page (docs/client/oauth-clients.md, the callback_handler paragraph) in this PR to state that request timeouts exclude the login and that any bound on the person belongs inside callback_handler, as AGENTS.md asks for user-visible behaviour changes. [also at: src/mcp/client/auth/oauth2.py:429 - nit: AGENTS.md asks that user-visible behaviour changes update the relevant docs/ page in the same PR. Wrapping redirect_handler/callback_handler in waiting_on_a_person() changes what read_timeout_seconds (and per-call timeout) measures: a request's timeout no longer fires while a person is in the browser, and an unfinished login is no longer ended by it.]

Why this was flagged

A client built with Client(..., read_timeout_seconds=30) and an OAuthClientProvider whose callback_handler waits on a local redirect listener is challenged with a 401 and the person closes the browser without finishing. On the base branch anyio.fail_after(opts.get("timeout")) in send_raw_request fires after 30 s and the caller gets MCPError(REQUEST_TIMEOUT). After this change waiting_on_a_person() at src/mcp/client/auth/oauth2.py:429 has moved the scope deadline to math.inf (src/mcp/shared/_request_clock.py:34), so the call blocks until the caller is cancelled. The diff touches no file under docs/; docs/client/oauth-clients.md:60 describes callback_handler without saying the request timeout no longer covers it, and only docs/migration.md:2487 hints at bounding inside the handler. AGENTS.md requires the relevant docs page to be updated in the same PR when user-visible behaviour changes.

Verification: nit — triggered when a caller relies on read_timeout_seconds (or a per-call timeout) to bound an interactive login whose callback_handler never returns; the diff changes that user-visible behaviour and updates no page under docs/, which AGENTS.md:144-146 requires in the same PR. git diff c15566c HEAD -- docs/ is empty.


if result.state is None or not secrets.compare_digest(result.state, state):
raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}")
Expand Down
81 changes: 81 additions & 0 deletions src/mcp/shared/_request_clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""A request's timeout measures the peer, so its clock stops while the request waits on a person.
The clock travels in a `ContextVar`: the HTTP transports run each outgoing message in its sender's
context, which is how code far below `send_raw_request` finds the clock of the request it serves.
Nothing in this module is public API: it may change or be removed without notice. It is likely to
change with the client dispatcher work in https://github.com/modelcontextprotocol/python-sdk/pull/3517.
"""

import math
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar

import anyio


class RequestClock:
"""One request's timeout, as a budget of seconds that is spent only while the clock runs.
Not public API: may change or be removed without notice.
"""

def __init__(self, timeout: float | None, scope: anyio.CancelScope) -> None:
self._budget = math.inf if timeout is None else timeout
self._scope = scope
self._pauses = 1 # the write, which is off the clock too; `start()` ends it

def start(self) -> None:
self.resume()

def pause(self) -> None:
if not self._pauses:
self._budget = self._scope.deadline - anyio.current_time()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: pause() can run after the deadline has elapsed but before AnyIO delivers its scheduled cancellation. It then moves the deadline to infinity, so an unfinished login can suppress the request timeout; cancel the scope when no budget remains.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/mcp/shared/_request_clock.py, line 34:

<comment>`pause()` can run after the deadline has elapsed but before AnyIO delivers its scheduled cancellation. It then moves the deadline to infinity, so an unfinished login can suppress the request timeout; cancel the scope when no budget remains.</comment>

<file context>
@@ -0,0 +1,81 @@
+
+    def pause(self) -> None:
+        if not self._pauses:
+            self._budget = self._scope.deadline - anyio.current_time()
+            self._scope.deadline = math.inf
+        self._pauses += 1
</file context>

self._scope.deadline = math.inf
Comment on lines +33 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users whose request budget ran out an instant before the 401 arrives now sit through the whole browser login and then still get Request ... timed out, instead of the prompt timeout the base fail_after gave. src/mcp/shared/_request_clock.py:34 reads a deadline that is already in the past when the runner has not yet delivered the expiry (trio expires deadlines only at the top of a scheduler pass; asyncio's timer handle is cancelled by the setter at :35 if it is still queued). The budget goes negative and :35 moves the deadline to inf for the entire login. Fix: in pause(), if deadline <= current_time() call self._scope.cancel() (or leave the deadline alone) instead of parking it at inf, so an exhausted budget fails the request before the person is sent to the browser.

Why this was flagged

Trigger: a request's budget expires in the same scheduler batch in which the challenged POST task receives the 401 and enters waiting_on_a_person() (oauth2.py:429). On trio the scope's cancel_called is only set when the run loop processes the deadline heap at the start of the next pass, so at _request_clock.py:34 self._scope.deadline is finite and already past; _budget becomes negative and :35 sets deadline = inf, removing the scope from the heap. On asyncio the same window exists when the timer handle is in the ready queue behind the POST task's step: the setter cancels the handle before it runs. Result: the person is sent to the browser, spends minutes logging in, and at resume() (:41) the negative budget fires the cancel so the caller gets REQUEST_TIMEOUT anyway. Base branch: fail_after fires at the deadline and the login never starts. Remedy: have pause() detect an already-exhausted budget and cancel the scope immediately rather than parking the deadline at inf.

Verification: pause() does self._budget = self._scope.deadline - anyio.current_time() (src/mcp/shared/_request_clock.py:34) with no clamp. If the deadline is already in the past but the backend has not yet delivered the expiry, _budget goes negative. So the caller waits through the entire browser login and then still receives the timeout, whereas the base fail_after would have delivered it at the next pass.

self._pauses += 1

def resume(self) -> None:
self._pauses -= 1
if not self._pauses:
self._scope.deadline = anyio.current_time() + self._budget


_clock: ContextVar[RequestClock | None] = ContextVar("request_clock", default=None)


@contextmanager
def request_clock(timeout: float | None) -> Iterator[RequestClock]:
"""Put a clock, not yet started, in the context of the request sent in this block.
Raises `TimeoutError` once the clock has run for `timeout` seconds.
Not public API: may change or be removed without notice.
"""
with anyio.CancelScope() as scope:
clock = RequestClock(timeout, scope)
token = _clock.set(clock)
try:
yield clock
finally:
_clock.reset(token)
# Not `fail_after`: it re-reads the deadline here, which a pause may have moved since it expired.
if scope.cancelled_caught:
raise TimeoutError


@contextmanager
def waiting_on_a_person() -> Iterator[None]:
"""Stop the clock of the request this code is serving while the block is open; a no-op if there is none.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing: Callers with several requests in flight when a token expires still see every request but one fail with REQUEST_TIMEOUT during the login. waiting_on_a_person() at src/mcp/shared/_request_clock.py:70 finds only the challenged request's clock; the others wait for self.context.lock at src/mcp/client/auth/oauth2.py:606 on their own clocks. Fix: a request queued on the provider lock while a login is in progress must have its clock paused as well, e.g. record a login-in-progress event in the context and wrap the lock wait in waiting_on_a_person() while it is set. The requirement client-auth:login-time:other-requests-keep-counting pins this as a known limit; the failure is still a timeout nobody can avoid. [also at: src/mcp/client/auth/oauth2.py:429 - pre-existing: Callers with other requests in flight when an interactive login starts still get Request ... timed out for those requests.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

An application with Client(..., read_timeout_seconds=30) fans out list_tools, list_prompts and list_resources concurrently after its access token expired and the authorization server issued no refresh token. All three POSTs hit _auth_flow in src/mcp/client/auth/oauth2.py:604; the first takes self.context.lock at src/mcp/client/auth/oauth2.py:606, gets the 401 and enters waiting_on_a_person() at src/mcp/client/auth/oauth2.py:429, which pauses that one request's clock via src/mcp/shared/_request_clock.py:70. The other two block on the lock with their clocks running, so after 30 s send_raw_request raises MCPError(code=REQUEST_TIMEOUT) for each, as tests/interaction/auth/test_login_time.py:341 pins at 5.0 s. The base branch times out all requests, including the challenged one; the change fixes only the one holding the lock.

Verification: pre-existing; acknowledged in diff: tests/interaction/auth/test_login_time.py:309-311 pins this outcome. waiting_on_a_person() (src/mcp/shared/_request_clock.py:70-81) pauses only the challenged task's clock; other requests block at src/mcp/client/auth/oauth2.py:606 with clocks running, so each fails with REQUEST_TIMEOUT. The base commit timed out every request, so merging makes nothing worse.

Not public API: may change or be removed without notice.
"""
clock = _clock.get()
if clock is None:
yield
return
clock.pause()
try:
yield
finally:
clock.resume()
16 changes: 9 additions & 7 deletions src/mcp/shared/jsonrpc_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

from mcp.shared._compat import resync_tracer
from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared._request_clock import request_clock
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import (
CallOptions,
Expand Down Expand Up @@ -404,12 +405,13 @@ async def send_raw_request(
# never started; past this point a cancelled write counts as issued.
await anyio.lowlevel.checkpoint_if_cancelled()
request_write_started = True
try:
await self._write(msg, plan.metadata)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
# Transport tore down before run() noticed EOF; surface the documented contract.
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None
with anyio.fail_after(opts.get("timeout")):
with request_clock(opts.get("timeout")) as clock:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md says any change to an existing API's observable behaviour is an explicit maintainer design decision and should generally be avoided. Replacing anyio.fail_after(opts.get("timeout")) with request_clock(...) changes the observable semantics of Client.read_timeout_seconds / call timeout on 2.x: the budget now pauses for the whole of an OAuth login, so a request that previously raised REQUEST_TIMEOUT after N seconds can now outlive N indefinitely. Fix: have a maintainer explicitly sign off on the new timeout semantics (the PR links issue #3601 and states the trade-offs), and record the decision in the PR/docs rather than leaving it implicit in a private module.

Why this was flagged

Nothing fails at runtime for the happy path. The instruction guards the 2.x compatibility contract: existing callers who relied on read_timeout_seconds to bound an entire call_tool/initialize (including a stalled login) now see that bound suspended while redirect_handler/callback_handler run, so a login nobody completes hangs the call until the caller cancels. The PR author presents this as intended and scoped (private module, OAuthClientProvider only, revisited with #3517), which is the information the maintainer needs to make the call the instruction asks for.

Verification: AGENTS.md (base commit, "Branching Model") says: "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."

try:
await self._write(msg, plan.metadata)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
# Transport tore down before run() noticed EOF; surface the documented contract.
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None
clock.start()
timeout_armed = True
outcome = await receive.receive()
if isinstance(outcome, ErrorData) and outcome is not _CLOSED_OUTCOME:
Expand All @@ -418,7 +420,7 @@ async def send_raw_request(
span.set_status(StatusCode.ERROR, outcome.message)
except TimeoutError:
if not timeout_armed:
# `fail_after` arms only after the write, so this TimeoutError is the
# The clock starts only after the write, so this TimeoutError is the
# transport's own bounded send() failing - a transport error, not
# `opts["timeout"]` elapsing. Propagate it raw (v1 kept the write
# outside the timeout-catching try and did the same).
Expand Down
28 changes: 28 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,15 @@ def __post_init__(self) -> None:
),
added_in="2026-07-28",
),
"lifecycle:discover:fallback-silence": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility",
behavior=(
"When server/discover goes unanswered for the probe deadline, an auto-negotiating client falls "
"back to the legacy initialize handshake."
),
added_in="2026-07-28",
note="The spec states the timeout rule for stdio only; the SDK applies it on every transport.",
),
"lifecycle:discover:network-error-raises": Requirement(
source="sdk",
behavior=(
Expand Down Expand Up @@ -3826,6 +3835,25 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:login-time:not-counted": Requirement(
source="issue:#3601",
behavior=(
"The time OAuthClientProvider spends awaiting redirect_handler and callback_handler does not count "
"against the timeout of the request that was challenged; afterwards the timeout resumes with the "
"budget that was left. The provider's own HTTP calls do count."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:login-time:other-requests-keep-counting": Requirement(
source="sdk",
behavior=(
"A login suspends the timeout of the challenged request only: a request queued behind it in the "
"provider still times out on its own clock."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:pkce:refuse-if-unsupported": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection",
behavior=(
Expand Down
12 changes: 10 additions & 2 deletions tests/interaction/auth/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,9 @@ async def connect_with_oauth(
verify_tokens: bool = True,
app_shim: Callable[[ASGIApp], ASGIApp] | None = None,
on_request: Callable[[httpx2.Request], None] | None = None,
mode: str = "legacy",
read_timeout_seconds: float | None = None,
json_response: bool = False,
) -> AsyncIterator[tuple[Client, HeadlessOAuth]]:
"""Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process.
Expand Down Expand Up @@ -455,6 +458,7 @@ async def connect_with_oauth(
)

app: ASGIApp = server.streamable_http_app(
json_response=json_response,
auth=settings,
token_verifier=ProviderTokenVerifier(provider) if verify_tokens else None,
auth_server_provider=provider,
Expand All @@ -481,7 +485,11 @@ async def hook(request: httpx2.Request) -> None:
)
headless.bind(http_client)
client = await stack.enter_async_context(
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape.
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy")
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape, hence the default mode.
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
mode=mode,
read_timeout_seconds=read_timeout_seconds,
)
)
yield client, headless
Loading
Loading