-
Notifications
You must be signed in to change notification settings - Fork 4k
Stop counting an interactive OAuth login against request timeouts #3635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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(): | ||
| await self.context.redirect_handler(authorization_url) | ||
| result = await self.context.callback_handler() | ||
|
Comment on lines
+429
to
+431
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): Users who set Why this was flaggedA client built with Verification: nit — triggered when a caller relies on |
||
|
|
||
| if result.state is None or not secrets.compare_digest(result.state, state): | ||
| raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}") | ||
|
|
||
| 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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| self._scope.deadline = math.inf | ||
|
Comment on lines
+33
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedTrigger: a request's budget expires in the same scheduler batch in which the challenged POST task receives the 401 and enters Verification: |
||
| 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedAn application with Verification: pre-existing; acknowledged in diff: tests/interaction/auth/test_login_time.py:309-311 pins this outcome. |
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedNothing fails at runtime for the happy path. The instruction guards the 2.x compatibility contract: existing callers who relied on 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: | ||
|
|
@@ -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). | ||
|
|
||
There was a problem hiding this comment.
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 clocksend_raw_requestinstalls; tworead_timeout_secondsbounds sit outside it as plainanyio.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'sopts["timeout"]and drop the outerfail_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 islisten()still gets aTimeoutErrorwhen the login outlastsread_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 anOAuthClientProviderwhose token is missing or expired opens a subscription as its first call.drive()in src/mcp/client/subscriptions.py:250 sends thelistenrequest with noopts["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 inanyio.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 raisesTimeoutErrorout of the subscribe context manager. The same holds at src/mcp/client/client.py:833, whereanyio.fail_after(timeout)wraps_relist_tooland a 401 on the re-list ends inraise 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 insidesend_raw_request.Verification: The pause at src/mcp/client/auth/oauth2.py:429 only touches the
RequestClockinstalled at src/mcp/shared/jsonrpc_dispatcher.py:408. src/mcp/client/subscriptions.py:271-272 waits in a plainanyio.fail_afterthat keeps counting while the login blocks the POST. src/mcp/client/client.py:833 wraps_relist_toolinanyio.fail_after(timeout). Both files are untouched by the PR, so the base fails identically.