Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
--8<-- "docs_src/client_transports/tutorial002.py"

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: The prose in this section now contradicts the embedded tutorial003.py snippet. The snippet was updated from a plain httpx.AsyncClient to create_mcp_http_client(...), but downstream text (around line 46) still tells the reader to "build the httpx.AsyncClient yourself" and the info box claims "The SDK adds nothing on top and takes nothing away." Both are now false — create_mcp_http_client installs the _raise_on_disallowed_redirect response hook and applies MCP default timeouts. This creates conflicting guidance with docs/migration.md, which explicitly warns against using a plain httpx.AsyncClient. The surrounding prose (and the httpx.AsyncClient(auth=OAuthClientProvider(...)) example in the info box) should be updated to match the factory-based approach the snippet now demonstrates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/client/transports.md, line 32:

<comment>The prose in this section now contradicts the embedded `tutorial003.py` snippet. The snippet was updated from a plain `httpx.AsyncClient` to `create_mcp_http_client(...)`, but downstream text (around line 46) still tells the reader to "build the `httpx.AsyncClient` yourself" and the info box claims "The SDK adds nothing on top and takes nothing away." Both are now false — `create_mcp_http_client` installs the `_raise_on_disallowed_redirect` response hook and applies MCP default timeouts. This creates conflicting guidance with `docs/migration.md`, which explicitly warns against using a plain `httpx.AsyncClient`. The surrounding prose (and the `httpx.AsyncClient(auth=OAuthClientProvider(...))` example in the info box) should be updated to match the factory-based approach the snippet now demonstrates.</comment>

<file context>
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi

-That is the whole production client. Client wraps the URL in streamable_http_client(...) for you, on top of an httpx.AsyncClient configured the way MCP needs: follow_redirects=True, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
+That is the whole production client. Client wraps the URL in streamable_http_client(...) for you, on top of an httpx.AsyncClient configured the way MCP needs: same-origin redirect handling, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.

!!! check
</file context>


</details>

```

That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: same-origin redirect handling, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.

Check warning on line 32 in docs/client/transports.md

View check run for this annotation

Claude / Claude Code Review

transports.md prose contradicts updated tutorial003 snippet

The 'Bring your own `httpx.AsyncClient`' section's prose now contradicts the tutorial003 snippet it embeds: this PR switched the snippet to `create_mcp_http_client`, but line 46 still says to "build the `httpx.AsyncClient` yourself" and the info box still claims "The SDK adds nothing on top and takes nothing away" — no longer true, since the factory adds the same-origin redirect policy (raising `mcp.RedirectError`) and MCP default timeouts. Update the prose (and the `httpx.AsyncClient(auth=OAuth

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.

🟡 The 'Bring your own httpx.AsyncClient' section's prose now contradicts the tutorial003 snippet it embeds: this PR switched the snippet to create_mcp_http_client, but line 46 still says to "build the httpx.AsyncClient yourself" and the info box still claims "The SDK adds nothing on top and takes nothing away" — no longer true, since the factory adds the same-origin redirect policy (raising mcp.RedirectError) and MCP default timeouts. Update the prose (and the httpx.AsyncClient(auth=OAuthClientProvider(...)) example) to match the snippet and the new migration.md guidance that prefers the factory.

Extended reasoning...

What the bug is. docs/client/transports.md has a section titled "Bring your own httpx.AsyncClient" that embeds docs_src/client_transports/tutorial003.py. This PR rewrote that snippet from a plain httpx.AsyncClient(...) with follow_redirects=True to create_mcp_http_client(...), and updated the Streamable HTTP intro paragraph (line 32) accordingly — but the rest of the section's prose was left untouched, so it now contradicts the very code it displays.

The specific contradictions. Line 46 tells the reader to "build the httpx.AsyncClient yourself and hand it to streamable_http_client", and the info box near line 70 says: "The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: httpx.AsyncClient(auth=OAuthClientProvider(...))." Both statements described the old snippet accurately. They do not describe the new one: create_mcp_http_client demonstrably adds SDK behavior on top of httpx — it installs the _raise_on_disallowed_redirect response event hook (which raises the new mcp.RedirectError on cross-origin redirects) and applies MCP default timeouts (30s connect/write/pool, 300s read). "Adds nothing on top" is now a specific, false claim sitting directly around code that proves otherwise.

Why this matters beyond cosmetics. The stale prose actively steers readers toward constructing a plain httpx.AsyncClient — which is exactly the pitfall this PR's own docs/migration.md addition warns about: "a plain httpx.AsyncClient does not follow redirects at all, and configuring follow_redirects=True yourself sends everything on the client (headers, auth, bodies) to whichever server a redirect names." So the SDK's documentation now gives conflicting guidance on the same question, with transports.md recommending the pattern migration.md says to avoid.

Step-by-step proof. (1) A reader lands on the "Bring your own" section needing an Authorization header. (2) Following line 46 and the info box literally, they write httpx.AsyncClient(headers={"Authorization": "Bearer ..."}) — a plain client, no follow_redirects. (3) Their server is Starlette-mounted at /notes/ and they connect to /notes (the trailing-slash case docs/run/asgi.md now explicitly calls out as answering with a redirect). (4) The plain client follows no redirects; the transport's new response.has_redirect_location check fires and the request resolves with a RedirectError-derived JSON-RPC error telling them to connect to the final URL — a loud, recoverable failure, but one the page's own snippet (which uses the factory and follows the same-origin redirect transparently) would never have produced. (5) Meanwhile the reader is left believing "the SDK adds nothing on top" while the embedded example's client raises mcp.RedirectError on any cross-origin redirect — behavior they were told doesn't exist.

Why nothing prevents it. The docs snippets are executed as code (so tutorial003 itself is correct and tested), but prose is not checked against the snippets it surrounds, so the inconsistency ships silently.

How to fix. Rewrite the section prose to match the snippet: say to build the client with create_mcp_http_client (which returns an httpx.AsyncClient with the SDK defaults — timeouts and same-origin redirect handling) and hand it to streamable_http_client. In the info box, drop "The SDK adds nothing on top and takes nothing away" and update the OAuth example to create_mcp_http_client(auth=OAuthClientProvider(...)), matching what docs_src/oauth_clients/tutorial001.py and migration.md now show. Nothing breaks at runtime — this is a docs-consistency fix, hence nit severity.


!!! check
A `Client` you have constructed is **not** connected. Construction only picks the transport;
Expand Down
35 changes: 27 additions & 8 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1595,14 +1595,15 @@ async with streamablehttp_client(

```python
import httpx
from mcp import create_mcp_http_client
from mcp.client.streamable_http import streamable_http_client

# Configure headers, timeout, and auth on the httpx.AsyncClient
http_client = httpx.AsyncClient(
# Configure headers, timeout, and auth on the client. create_mcp_http_client
# applies the SDK defaults (timeouts, same-origin redirect handling).
http_client = create_mcp_http_client(
headers={"Authorization": "Bearer token"},
timeout=httpx.Timeout(30, read=300),
auth=my_auth,
follow_redirects=True,
)

async with http_client:
Expand All @@ -1613,7 +1614,26 @@ async with http_client:
...
```

v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
Prefer `create_mcp_http_client` when supplying your own client: a plain `httpx.AsyncClient` does not follow redirects at all, and configuring `follow_redirects=True` yourself sends everything on the client (headers, auth, bodies) to whichever server a redirect names.

### HTTP clients only follow same-origin redirects

Clients created by the SDK (including `Client("http://...")`, `sse_client`, and the
default client inside `streamable_http_client`) now vet redirect targets before
following. Redirects that stay on the same origin (scheme, host, and port) and
http-to-https upgrades of the same host still work; any other redirect raises

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The migration note overstates which HTTPS-upgrade redirects remain allowed. The redirect policy described for this change only preserves same-host HTTP→HTTPS upgrades on default ports, so adding that qualifier here would avoid misleading deployments that use custom ports into expecting those redirects to be followed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 1624:

<comment>The migration note overstates which HTTPS-upgrade redirects remain allowed. The redirect policy described for this change only preserves same-host HTTP→HTTPS upgrades on default ports, so adding that qualifier here would avoid misleading deployments that use custom ports into expecting those redirects to be followed.</comment>

<file context>
@@ -1613,7 +1614,26 @@ async with http_client:
+Clients created by the SDK (including `Client("http://...")`, `sse_client`, and the
+default client inside `streamable_http_client`) now vet redirect targets before
+following. Redirects that stay on the same origin (scheme, host, and port) and
+http-to-https upgrades of the same host still work; any other redirect raises
+`mcp.RedirectError` naming the target instead of being followed. Everything
+configured on a client - headers, auth, request bodies - is sent to whichever
</file context>
Suggested change
http-to-https upgrades of the same host still work; any other redirect raises
http-to-https upgrades of the same host on default ports still work; any other redirect raises

`mcp.RedirectError` naming the target instead of being followed. Everything
configured on a client - headers, auth, request bodies - is sent to whichever
server it talks to, so requests now stay with the server you configured, and a
misconfigured URL fails with a clear error instead of silently bouncing.

To migrate: connect to the final URL the error names. If your deployment
genuinely requires following a redirect to a different origin, supply your own
`httpx.AsyncClient(follow_redirects=True)` - after verifying you trust every
destination in the chain.

`create_mcp_http_client` (the SDK's client factory) and `RedirectError` are now
exported from the top-level `mcp` package.

### `get_session_id` callback removed from `streamable_http_client`

Expand All @@ -1638,6 +1658,7 @@ async with streamable_http_client(url) as (read_stream, write_stream, get_sessio

```python
import httpx
from mcp import create_mcp_http_client
from mcp.client.streamable_http import streamable_http_client

# Option 1: Simply ignore if you don't need the session ID
Expand All @@ -1653,10 +1674,8 @@ async def capture_session_id(response: httpx.Response) -> None:
if session_id:
captured_session_ids.append(session_id)

http_client = httpx.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
http_client = create_mcp_http_client()
http_client.event_hooks["response"].append(capture_session_id)

async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
Expand Down
2 changes: 1 addition & 1 deletion docs/run/asgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
--8<-- "docs_src/asgi/tutorial004.py"
```

Now clients connect to `/notes`, not `/notes/mcp`.
Now clients connect to `/notes/`, not `/notes/mcp`. (The trailing slash is the canonical path for this mount shape; the bare `/notes` answers with a same-origin redirect to it.)

## CORS for browser clients

Expand Down
5 changes: 2 additions & 3 deletions docs_src/client_transports/tutorial003.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import httpx

from mcp import Client
from mcp import Client, create_mcp_http_client
from mcp.client.streamable_http import streamable_http_client


async def main() -> None:
async with httpx.AsyncClient(
async with create_mcp_http_client(
headers={"Authorization": "Bearer ..."},
timeout=httpx.Timeout(30.0, read=300.0),
follow_redirects=True,
) as http_client:
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
async with Client(transport) as client:
Expand Down
5 changes: 2 additions & 3 deletions docs_src/identity_assertion/tutorial001.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import time
import uuid

import httpx
import jwt

from mcp import Client
from mcp import Client, create_mcp_http_client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
Expand Down Expand Up @@ -62,7 +61,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str:


async def main() -> None:
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with create_mcp_http_client(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
5 changes: 2 additions & 3 deletions docs_src/oauth_clients/tutorial001.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from urllib.parse import parse_qs, urlparse

import httpx
from pydantic import AnyUrl

from mcp import Client
from mcp import Client, create_mcp_http_client
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
Expand Down Expand Up @@ -55,7 +54,7 @@ async def wait_for_callback() -> AuthorizationCodeResult:


async def main() -> None:
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with create_mcp_http_client(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
6 changes: 2 additions & 4 deletions docs_src/oauth_clients/tutorial002.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import httpx

from mcp import Client
from mcp import Client, create_mcp_http_client
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
Expand Down Expand Up @@ -34,7 +32,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None


async def main() -> None:
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with create_mcp_http_client(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Any
from urllib.parse import parse_qs, urlparse

import httpx
from mcp import create_mcp_http_client
from mcp.client._transport import ReadStream, WriteStream
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
from mcp.client.session import ClientSession
Expand Down Expand Up @@ -233,7 +233,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
await self._run_session(read_stream, write_stream)
else:
print("📡 Opening StreamableHTTP transport connection with auth...")
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with create_mcp_http_client(auth=oauth_auth) as custom_client:
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
read_stream,
write_stream,
Expand Down
7 changes: 5 additions & 2 deletions examples/servers/simple-tool/mcp_simple_tool/server.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import anyio
import click
import httpx
import mcp_types as types
from mcp.server import Server, ServerRequestContext
from mcp.shared._httpx_utils import create_mcp_http_client


async def fetch_website(
url: str,
) -> list[types.ContentBlock]:
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
async with create_mcp_http_client(headers=headers) as client:
# A general-purpose web fetch wants ordinary browser-like redirect
# behavior; create_mcp_http_client is for talking to a configured MCP
# endpoint and only follows same-origin redirects.
async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
return [types.TextContent(type="text", text=response.text)]
Expand Down
6 changes: 2 additions & 4 deletions examples/snippets/clients/identity_assertion_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@

import asyncio

import httpx

from mcp import ClientSession
from mcp import ClientSession, create_mcp_http_client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
Expand Down Expand Up @@ -66,7 +64,7 @@ async def main() -> None:
scope="user",
)

async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
async with create_mcp_http_client(auth=oauth_auth) as http_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
5 changes: 2 additions & 3 deletions examples/snippets/clients/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
import asyncio
from urllib.parse import parse_qs, urlparse

import httpx
from pydantic import AnyUrl

from mcp import ClientSession
from mcp import ClientSession, create_mcp_http_client
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
Expand Down Expand Up @@ -72,7 +71,7 @@ async def main():
callback_handler=handle_callback,
)

async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with create_mcp_http_client(auth=oauth_auth) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from .client.stdio import StdioServerParameters, stdio_client
from .server.session import ServerSession
from .server.stdio import stdio_server
from .shared._httpx_utils import RedirectError, create_mcp_http_client
from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError
from .shared.uri_template import InvalidUriTemplate, UriTemplate

Expand Down Expand Up @@ -108,6 +109,7 @@
"PromptsCapability",
"ReadResourceRequest",
"ReadResourceResult",
"RedirectError",
"Resource",
"ResourcesCapability",
"ResourceUpdatedNotification",
Expand Down Expand Up @@ -137,6 +139,7 @@
"UriTemplate",
"UrlElicitationRequiredError",
"InvalidUriTemplate",
"create_mcp_http_client",
"stdio_client",
"stdio_server",
]
3 changes: 3 additions & 0 deletions src/mcp/client/auth/extensions/identity_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
union_scopes,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import redirect_error
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url

Expand Down Expand Up @@ -199,6 +200,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.

assertion = await self._assertion_provider(self._issuer, self._resource)
token_response = yield self._build_token_request(scope_to_request, assertion)
if token_response.has_redirect_location:
raise redirect_error(token_response, context="OAuth token exchange request")
if token_response.status_code != 200:
body = (await token_response.aread()).decode(errors="replace")
raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}")
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import redirect_error
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
Expand Down Expand Up @@ -413,9 +414,11 @@
token_data, headers = self.context.prepare_token_auth(token_data, headers)

return httpx.Request("POST", token_url, data=token_data, headers=headers)

async def _handle_token_response(self, response: httpx.Response) -> None:
"""Handle token exchange response."""
if response.has_redirect_location:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: OAuthClientProvider._handle_protected_resource_response (around line 281) is a near-duplicate of utils.handle_protected_resource_response but wasn't updated with the same has_redirect_location check that all sibling handlers here received. It's currently dead code in src/ (the live discovery path calls the utils version, which was updated), but the two copies now embody different redirect policies. Consider either deleting the dead method (repointing its tests at the utils function) or adding the same check so the copies stay aligned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/oauth2.py, line 420:

<comment>`OAuthClientProvider._handle_protected_resource_response` (around line 281) is a near-duplicate of `utils.handle_protected_resource_response` but wasn't updated with the same `has_redirect_location` check that all sibling handlers here received. It's currently dead code in `src/` (the live discovery path calls the utils version, which was updated), but the two copies now embody different redirect policies. Consider either deleting the dead method (repointing its tests at the utils function) or adding the same check so the copies stay aligned.</comment>

<file context>
@@ -416,6 +417,8 @@ async def _exchange_token_authorization_code(
 
     async def _handle_token_response(self, response: httpx.Response) -> None:
         """Handle token exchange response."""
+        if response.has_redirect_location:
+            raise redirect_error(response, context="OAuth token request")
         if response.status_code not in {200, 201}:
</file context>

raise redirect_error(response, context="OAuth token request")

Check warning on line 421 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

Dead duplicate _handle_protected_resource_response lacks the new redirect check

`OAuthClientProvider._handle_protected_resource_response` (oauth2.py:281) is a near-duplicate of `utils.handle_protected_resource_response` but didn't get the new `has_redirect_location` check, so a 307 through it still raises the old generic `OAuthFlowError` instead of `RedirectError` with guidance. The method is dead code in `src/` (only tests call it), so nothing fails at runtime — consider deleting it (repointing its tests at the utils function) or adding the same check so the two copies don
Comment on lines 417 to +421

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.

🟡 OAuthClientProvider._handle_protected_resource_response (oauth2.py:281) is a near-duplicate of utils.handle_protected_resource_response but didn't get the new has_redirect_location check, so a 307 through it still raises the old generic OAuthFlowError instead of RedirectError with guidance. The method is dead code in src/ (only tests call it), so nothing fails at runtime — consider deleting it (repointing its tests at the utils function) or adding the same check so the two copies don't diverge.

Extended reasoning...

What the bug is. This PR standardizes redirect handling across the OAuth response handlers: _handle_token_response (oauth2.py:420), _handle_refresh_response (oauth2.py:474), the identity-assertion token exchange, and the module-level mcp.client.auth.utils.handle_protected_resource_response / handle_auth_metadata_response / handle_registration_response all gained an early if response.has_redirect_location: raise redirect_error(...) check. One handler was left out: OAuthClientProvider._handle_protected_resource_response at src/mcp/client/auth/oauth2.py:281, which is a near-duplicate of the utils function.

Why nothing fails at runtime. Grep confirms the provider method has zero callers in src/ — the live discovery path in async_auth_flow calls the module-level utils.handle_protected_resource_response (which the PR did update). The only callers are tests/client/test_auth.py:543/563/583. So this is dead library code kept alive by its tests, and the divergence has no production failure scenario today.

What the divergence looks like. Concrete walkthrough of a 307 response through each copy:

  1. Server answers a PRM discovery GET with 307 Location: https://other.example.com/.well-known/oauth-protected-resource.
  2. Through the live path, utils.handle_protected_resource_response hits the new response.has_redirect_location check first and raises RedirectError("OAuth protected resource metadata request: server at ... responded with 307 redirecting to ...") — the loud, actionable error this PR set out to produce.
  3. Through the dead provider method, there is no such check: status_code == 200 is false, status_code == 404 is false, so the else branch raises the old generic OAuthFlowError("Protected Resource Metadata request failed: 307") — exactly the unhelpful failure mode the PR eliminates everywhere else. (Note that branch carries a # pragma: no cover, so even the tests don't exercise it.)

Why it matters. Two copies of the same logic now embody two different redirect policies. Any future caller (or a refactor that swaps the utils call back to the method — it was clearly the original implementation the utils function was extracted from) silently reverts to the old behavior. Since every sibling handler in the same class got the check, this copy was plainly missed rather than deliberately excluded.

How to fix. Preferably delete the dead method and repoint its three tests at utils.handle_protected_resource_response (which also removes the # pragma: no cover branches it carries). Alternatively, give it the same two-line has_redirect_location check as its siblings so the copies stay aligned.

Not a merge blocker: the method is unreachable in the production flow, so merging as-is breaks nothing — this is a code-hygiene/consistency cleanup.

if response.status_code not in {200, 201}:
body = await response.aread()
body_text = body.decode("utf-8")
Expand Down Expand Up @@ -468,6 +471,8 @@

async def _handle_refresh_response(self, response: httpx.Response) -> bool:
"""Handle token refresh response. Returns True if successful."""
if response.has_redirect_location:
raise redirect_error(response, context="OAuth token refresh request")
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
self.context.clear_tokens()
Expand Down
11 changes: 11 additions & 0 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import AnyUrl, ValidationError

from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.shared._httpx_utils import redirect_error
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
Expand Down Expand Up @@ -206,6 +207,10 @@ async def handle_protected_resource_response(
Returns:
ProtectedResourceMetadata if successfully discovered, None if we should try next URL
"""
if response.has_redirect_location:
# A redirect here would silently move discovery to another URL;
# fail loudly instead of treating it as a miss.
raise redirect_error(response, context="OAuth protected resource metadata request")
if response.status_code == 200:
try:
content = await response.aread()
Expand All @@ -221,6 +226,10 @@ async def handle_protected_resource_response(


async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuthMetadata | None]:
if response.has_redirect_location:
# A redirect here would silently abandon discovery (any non-4xx stops
# the fallback chain); fail loudly instead.
raise redirect_error(response, context="OAuth authorization server metadata request")
if response.status_code == 200:
try:
content = await response.aread()
Expand Down Expand Up @@ -293,6 +302,8 @@ def create_client_registration_request(

async def handle_registration_response(response: Response) -> OAuthClientInformationFull:
"""Handle registration response."""
if response.has_redirect_location:
raise redirect_error(response, context="OAuth client registration request")
if response.status_code not in (200, 201):
await response.aread()
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
Expand Down
Loading