Skip to content

Client(mode="auto") falls back to legacy when interactive OAuth exceeds the fixed server/discover timeout #3601

Description

@pablozamudio

Initial Checks

Release line

2.x (current stable)

Description

What happens

Client(mode="auto") probes server/discover before falling back to the legacy initialize handshake.

ClientSession.send_discover() applies a fixed internal timeout:

DISCOVER_TIMEOUT_SECONDS = 10.0

When the first MCP request triggers an interactive OAuth authorization-code flow, that timeout includes the time spent waiting for the user to complete browser authorization.

If authorization takes longer than 10 seconds:

  1. The client sends a modern server/discover request.
  2. The server returns 401, and the OAuth flow starts.
  3. The discovery request times out while waiting for the user callback.
  4. mode="auto" falls back to legacy initialize.
  5. OAuth completes and correctly retries the original authenticated server/discover.
  6. The retry receives a valid modern DiscoverResult, but the client has already selected the legacy protocol.

As a result, a modern upstream is exposed as legacy only on cold interactive OAuth flows. Once credentials are cached, the same upstream negotiates 2026-07-28 correctly.

Expected behavior

Interactive OAuth should not cause a modern server to be classified as legacy solely because user authorization took longer than the discovery timeout.

Environment

  • mcp==2.1.1
  • Python 3.12
  • Streamable HTTP transport
  • Client(mode="auto")
  • OAuthClientProvider

The current v2.2.0 source still defines DISCOVER_TIMEOUT_SECONDS = 10.0 and uses it directly in send_discover().

Suggested direction

A public configuration point for the discovery timeout would let interactive clients choose an appropriate bound, for example:

Client(
    transport,
    mode="auto",
    discover_timeout_seconds=300.0,
)

That value would need to flow into ClientSession.send_discover() instead of relying only on the fixed module-level constant.

Alternatively, the SDK could avoid counting time spent in an interactive OAuth authorization flow against the discovery timeout.

Example Code

import asyncio
import json

import anyio
import httpx2
from mcp.client import Client
from mcp.client.streamable_http import streamable_http_client


class SlowInteractiveOAuth(httpx2.Auth):
    """Models an OAuth flow which takes longer than the 10s discover timeout."""

    requires_response_body = True

    async def async_auth_flow(self, request: httpx2.Request):
        response = yield request

        if response.status_code == 401:
            # Simulate the user taking 11 seconds to complete browser authorization.
            await anyio.sleep(11)

            # Retry the exact original request, now authenticated.
            request.headers["Authorization"] = "Bearer access-token"
            yield request


async def handler(request: httpx2.Request) -> httpx2.Response:
    payload = json.loads(request.content)
    method = payload.get("method")
    request_id = payload.get("id")

    if method == "server/discover":
        if "authorization" not in request.headers:
            return httpx2.Response(
                401,
                headers={"WWW-Authenticate": "Bearer"},
            )

        # The authenticated retry is a valid modern response.
        return httpx2.Response(
            200,
            json={
                "jsonrpc": "2.0",
                "id": request_id,
                "result": {
                    "resultType": "complete",
                    "supportedVersions": ["2026-07-28"],
                    "capabilities": {},
                    "ttlMs": 0,
                    "cacheScope": "private",
                },
            },
        )

    if method == "initialize":
        return httpx2.Response(
            200,
            json={
                "jsonrpc": "2.0",
                "id": request_id,
                "result": {
                    "protocolVersion": "2025-11-25",
                    "capabilities": {},
                    "serverInfo": {
                        "name": "legacy-fallback",
                        "version": "1.0",
                    },
                },
            },
        )

    return httpx2.Response(202)


async def main() -> None:
    async with httpx2.AsyncClient(
        transport=httpx2.MockTransport(handler),
        auth=SlowInteractiveOAuth(),
    ) as http_client, Client(
        streamable_http_client(
            "https://mcp.example.test/mcp",
            http_client=http_client,
        ),
        mode="auto",
    ) as client:
        print(client.protocol_version)


asyncio.run(main())

Expected output:
2026-07-28

Actual output:
2025-11-25

Python & MCP Python SDK

Python 3.12
mcp==2.1.1

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions