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
15 changes: 9 additions & 6 deletions .github/actions/conformance/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
)
from mcp.client.context import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken

# Set up logging to stderr (stdout is for conformance test output)
logging.basicConfig(
Expand Down Expand Up @@ -119,6 +119,7 @@ class ConformanceOAuthCallbackHandler:
def __init__(self) -> None:
self._auth_code: str | None = None
self._state: str | None = None
self._iss: str | None = None

async def handle_redirect(self, authorization_url: str) -> None:
"""Fetch the authorization URL and extract the auth code from the redirect."""
Expand All @@ -140,6 +141,8 @@ async def handle_redirect(self, authorization_url: str) -> None:
self._auth_code = query_params["code"][0]
state_values = query_params.get("state")
self._state = state_values[0] if state_values else None
iss_values = query_params.get("iss")
self._iss = iss_values[0] if iss_values else None
Comment thread
Kludex marked this conversation as resolved.
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
return
else:
Expand All @@ -149,15 +152,15 @@ async def handle_redirect(self, authorization_url: str) -> None:
else:
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")

async def handle_callback(self) -> tuple[str, str | None]:
"""Return the captured auth code and state."""
async def handle_callback(self) -> AuthorizationCodeResult:
"""Return the captured auth code, state, and iss."""
if self._auth_code is None:
raise RuntimeError("No authorization code available - was handle_redirect called?")
auth_code = self._auth_code
state = self._state
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
self._auth_code = None
self._state = None
return auth_code, state
self._iss = None
return result


# --- Scenario Handlers ---
Expand Down
17 changes: 9 additions & 8 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ client:
- auth/token-endpoint-auth-post
- auth/token-endpoint-auth-none
- auth/offline-access-not-supported
# SEP-2468 (authorization response iss parameter) is implemented, but these
# 2026-introduced scenarios reach DCR and so still fail the application_type
# check above; they unblock with SEP-837, not SEP-2468.
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized

# --- Auth scenarios cut short by the 2026 connection lifecycle ---
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
Expand All @@ -65,14 +74,6 @@ client:
- http-invalid-tool-headers
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
- json-schema-ref-no-deref
# SEP-2468 (authorization response iss parameter): not implemented in the client.
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized
- auth/metadata-issuer-mismatch
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
Expand Down
17 changes: 9 additions & 8 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,21 @@ client:
- http-invalid-tool-headers
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
- json-schema-ref-no-deref
# SEP-2468 (authorization response iss parameter): not implemented in the client.
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
# SEP-837 (application_type during DCR): the check fires on every non-legacy
# spec version (the default LATEST is 2026-07-28). The client omits
# application_type during Dynamic Client Registration, so every scenario that
# reaches DCR fails it. SEP-2468 iss validation is implemented, so these now
# fail only on the application_type check, not on iss.
- auth/offline-access-not-supported
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized
- auth/metadata-issuer-mismatch
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
# SEP-837 (application_type during DCR): the check only fires on draft-version
# runs; this draft scenario is the one place the client still hits it.
- auth/offline-access-not-supported

# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator
Expand Down
10 changes: 7 additions & 3 deletions README.v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -2323,7 +2323,7 @@ import httpx
from pydantic import AnyUrl

from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
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 @@ -2356,10 +2356,14 @@ async def handle_redirect(auth_url: str) -> None:
print(f"Visit: {auth_url}")


async def handle_callback() -> tuple[str, str | None]:
async def handle_callback() -> AuthorizationCodeResult:
callback_url = input("Paste callback URL: ")
params = parse_qs(urlparse(callback_url).query)
return params["code"][0], params.get("state", [None])[0]
return AuthorizationCodeResult(
code=params["code"][0],
state=params.get("state", [None])[0],
iss=params.get("iss", [None])[0],
)


async def main():
Expand Down
29 changes: 29 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,35 @@ 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.

### OAuth `callback_handler` returns `AuthorizationCodeResult`

The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the RFC 9207 authorization-response issuer (SEP-2468): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`.

**Before (v1):**

```python
async def callback_handler() -> tuple[str, str | None]:
params = parse_qs(urlparse(await wait_for_redirect()).query)
return params["code"][0], params.get("state", [None])[0]
```

**After (v2):**

```python
from mcp.client.auth import AuthorizationCodeResult


async def callback_handler() -> AuthorizationCodeResult:
params = parse_qs(urlparse(await wait_for_redirect()).query)
return AuthorizationCodeResult(
code=params["code"][0],
state=params.get("state", [None])[0],
iss=params.get("iss", [None])[0],
)
```

Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it.

Comment thread
claude[bot] marked this conversation as resolved.
### `get_session_id` callback removed from `streamable_http_client`

The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.
Expand Down
21 changes: 14 additions & 7 deletions examples/clients/simple-auth-client/mcp_simple_auth_client/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import httpx
from mcp.client._transport import ReadStream, WriteStream
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
Expand Down Expand Up @@ -69,6 +69,7 @@ def do_GET(self):
if "code" in query_params:
self.callback_data["authorization_code"] = query_params["code"][0]
self.callback_data["state"] = query_params.get("state", [None])[0]
self.callback_data["iss"] = query_params.get("iss", [None])[0]
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
Expand Down Expand Up @@ -112,7 +113,7 @@ def __init__(self, port: int = 3000):
self.port = port
self.server = None
self.thread = None
self.callback_data = {"authorization_code": None, "state": None, "error": None}
self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None}

def _create_handler_with_data(self):
"""Create a handler class with access to callback data."""
Expand Down Expand Up @@ -156,10 +157,16 @@ def wait_for_callback(self, timeout: int = 300):
time.sleep(0.1)
raise Exception("Timeout waiting for OAuth callback")

def get_state(self):
"""Get the received state parameter."""
@property
def state(self):
"""The received state parameter."""
return self.callback_data["state"]

@property
def iss(self):
"""The received iss parameter."""
return self.callback_data["iss"]


class SimpleAuthClient:
"""Simple MCP client with auth support."""
Expand All @@ -183,12 +190,12 @@ async def connect(self):
callback_server = CallbackServer(port=3030)
callback_server.start()

async def callback_handler() -> tuple[str, str | None]:
"""Wait for OAuth callback and return auth code and state."""
async def callback_handler() -> AuthorizationCodeResult:
"""Wait for OAuth callback and return auth code, state, and iss."""
print("⏳ Waiting for authorization callback...")
try:
auth_code = callback_server.wait_for_callback(timeout=300)
return auth_code, callback_server.get_state()
return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss)
finally:
callback_server.stop()

Expand Down
10 changes: 7 additions & 3 deletions examples/snippets/clients/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pydantic import AnyUrl

from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
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 @@ -46,10 +46,14 @@ async def handle_redirect(auth_url: str) -> None:
print(f"Visit: {auth_url}")


async def handle_callback() -> tuple[str, str | None]:
async def handle_callback() -> AuthorizationCodeResult:
callback_url = input("Paste callback URL: ")
params = parse_qs(urlparse(callback_url).query)
return params["code"][0], params.get("state", [None])[0]
return AuthorizationCodeResult(
code=params["code"][0],
state=params.get("state", [None])[0],
iss=params.get("iss", [None])[0],
)


async def main():
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/client/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
PKCEParameters,
TokenStorage,
)
from mcp.shared.auth import AuthorizationCodeResult

__all__ = [
"AuthorizationCodeResult",
"OAuthClientProvider",
"OAuthFlowError",
"OAuthRegistrationError",
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/client/auth/extensions/client_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pydantic import BaseModel, Field

from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata


class ClientCredentialsOAuthProvider(OAuthClientProvider):
Expand Down Expand Up @@ -405,7 +405,7 @@ def __init__(
client_metadata: OAuthClientMetadata,
storage: TokenStorage,
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
timeout: float = 300.0,
jwt_parameters: JWTParameters | None = None,
) -> None:
Expand Down
23 changes: 16 additions & 7 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@
handle_token_response_scopes,
is_valid_client_metadata_url,
should_use_client_metadata_url,
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthMetadata,
Expand Down Expand Up @@ -97,7 +100,7 @@ class OAuthContext:
client_metadata: OAuthClientMetadata
Comment thread
Kludex marked this conversation as resolved.
storage: TokenStorage
redirect_handler: Callable[[str], Awaitable[None]] | None
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None
timeout: float = 300.0
client_metadata_url: str | None = None

Expand Down Expand Up @@ -227,7 +230,7 @@ def __init__(
client_metadata: OAuthClientMetadata,
storage: TokenStorage,
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
timeout: float = 300.0,
client_metadata_url: str | None = None,
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
Expand Down Expand Up @@ -356,16 +359,19 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
await self.context.redirect_handler(authorization_url)

# Wait for callback
auth_code, returned_state = await self.context.callback_handler()
result = await self.context.callback_handler()

if returned_state is None or not secrets.compare_digest(returned_state, state):
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")
if result.state is None or not secrets.compare_digest(result.state, state):
raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}")

if not auth_code:
# RFC 9207: validate the authorization-response issuer
validate_authorization_response_iss(result.iss, self.context.oauth_metadata)
Comment thread
Kludex marked this conversation as resolved.

if not result.code:
raise OAuthFlowError("No authorization code received")

# Return auth code and code verifier for token exchange
return auth_code, pkce_params.code_verifier
return result.code, pkce_params.code_verifier

def _get_token_endpoint(self) -> str:
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
Expand Down Expand Up @@ -570,6 +576,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
if not ok:
break
if ok and asm:
# SEP-2468: metadata issuer must match the discovery issuer
if self.context.auth_server_url is not None:
validate_metadata_issuer(asm, self.context.auth_server_url)
Comment thread
Kludex marked this conversation as resolved.
self.context.oauth_metadata = asm
break
else:
Expand Down
Loading
Loading