Skip to content

Commit 8e255ad

Browse files
committed
Merge remote-tracking branch 'origin/main' into sep-2106-json-schema-ref
# Conflicts: # .github/actions/conformance/expected-failures.2026-07-28.yml # .github/actions/conformance/expected-failures.yml
2 parents a06d8e1 + f5fe42f commit 8e255ad

25 files changed

Lines changed: 434 additions & 134 deletions

File tree

.github/actions/conformance/client.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
)
4444
from mcp.client.context import ClientRequestContext
4545
from mcp.client.streamable_http import streamable_http_client
46-
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
46+
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
4747

4848
# Set up logging to stderr (stdout is for conformance test output)
4949
logging.basicConfig(
@@ -119,6 +119,7 @@ class ConformanceOAuthCallbackHandler:
119119
def __init__(self) -> None:
120120
self._auth_code: str | None = None
121121
self._state: str | None = None
122+
self._iss: str | None = None
122123

123124
async def handle_redirect(self, authorization_url: str) -> None:
124125
"""Fetch the authorization URL and extract the auth code from the redirect."""
@@ -140,6 +141,8 @@ async def handle_redirect(self, authorization_url: str) -> None:
140141
self._auth_code = query_params["code"][0]
141142
state_values = query_params.get("state")
142143
self._state = state_values[0] if state_values else None
144+
iss_values = query_params.get("iss")
145+
self._iss = iss_values[0] if iss_values else None
143146
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
144147
return
145148
else:
@@ -149,15 +152,15 @@ async def handle_redirect(self, authorization_url: str) -> None:
149152
else:
150153
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
151154

152-
async def handle_callback(self) -> tuple[str, str | None]:
153-
"""Return the captured auth code and state."""
155+
async def handle_callback(self) -> AuthorizationCodeResult:
156+
"""Return the captured auth code, state, and iss."""
154157
if self._auth_code is None:
155158
raise RuntimeError("No authorization code available - was handle_redirect called?")
156-
auth_code = self._auth_code
157-
state = self._state
159+
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
158160
self._auth_code = None
159161
self._state = None
160-
return auth_code, state
162+
self._iss = None
163+
return result
161164

162165

163166
# --- Scenario Handlers ---

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
# 2026 leg reads the `server:` section. Both burn down independently of the
1111
# 2025 legs.
1212
#
13-
# Baseline established against @modelcontextprotocol/conformance pinned in
14-
# .github/workflows/conformance.yml (CONFORMANCE_VERSION = 0.2.0-alpha.4).
15-
# New conformance releases are adopted by deliberately bumping that pin and
16-
# reconciling both this file and expected-failures.yml in the same change.
13+
# Baseline established against the harness pinned via CONFORMANCE_PKG in
14+
# .github/workflows/conformance.yml. New conformance releases are adopted by
15+
# deliberately bumping that pin and reconciling both this file and
16+
# expected-failures.yml in the same change.
1717
#
1818
# Entries are grouped by what unblocks them. As each gap closes the
1919
# corresponding scenarios start passing and MUST be removed from this list
@@ -44,6 +44,15 @@ client:
4444
- auth/token-endpoint-auth-post
4545
- auth/token-endpoint-auth-none
4646
- auth/offline-access-not-supported
47+
# SEP-2468 (authorization response iss parameter) is implemented, but these
48+
# 2026-introduced scenarios reach DCR and so still fail the application_type
49+
# check above; they unblock with SEP-837, not SEP-2468.
50+
- auth/iss-supported
51+
- auth/iss-not-advertised
52+
- auth/iss-supported-missing
53+
- auth/iss-wrong-issuer
54+
- auth/iss-unexpected
55+
- auth/iss-normalized
4756

4857
# --- Auth scenarios cut short by the 2026 connection lifecycle ---
4958
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
@@ -63,14 +72,6 @@ client:
6372
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
6473
- http-custom-headers
6574
- http-invalid-tool-headers
66-
# SEP-2468 (authorization response iss parameter): not implemented in the client.
67-
- auth/iss-supported
68-
- auth/iss-not-advertised
69-
- auth/iss-supported-missing
70-
- auth/iss-wrong-issuer
71-
- auth/iss-unexpected
72-
- auth/iss-normalized
73-
- auth/metadata-issuer-mismatch
7475
# SEP-2352 (authorization server migration): client does not re-register when
7576
# PRM authorization_servers changes.
7677
- auth/authorization-server-migration

.github/actions/conformance/expected-failures.yml

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# Conformance scenarios not yet passing against the Python SDK on main.
22
# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries.
33
#
4-
# Baseline established against @modelcontextprotocol/conformance pinned in
5-
# .github/workflows/conformance.yml (CONFORMANCE_VERSION = 0.2.0-alpha.4).
6-
# New conformance releases are adopted by deliberately bumping that pin and
7-
# reconciling both this file and expected-failures.2026-07-28.yml in the same
8-
# change.
4+
# Baseline established against the harness pinned via CONFORMANCE_PKG in
5+
# .github/workflows/conformance.yml. New conformance releases are adopted by
6+
# deliberately bumping that pin and reconciling both this file and
7+
# expected-failures.2026-07-28.yml in the same change.
98
#
109
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
1110
# scenarios start passing and MUST be removed from this list (the runner fails
@@ -22,25 +21,23 @@ client:
2221
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
2322
- http-custom-headers
2423
- http-invalid-tool-headers
25-
# SEP-2468 (authorization response iss parameter): not implemented in the client.
24+
# SEP-2352 (authorization server migration): client does not re-register when
25+
# PRM authorization_servers changes.
26+
- auth/authorization-server-migration
27+
# SEP-837 (application_type during DCR): the check fires on every non-legacy
28+
# spec version (the default LATEST is 2026-07-28). The client omits
29+
# application_type during Dynamic Client Registration, so every scenario that
30+
# reaches DCR fails it. SEP-2468 iss validation is implemented, so these now
31+
# fail only on the application_type check, not on iss.
32+
- auth/offline-access-not-supported
2633
- auth/iss-supported
2734
- auth/iss-not-advertised
2835
- auth/iss-supported-missing
2936
- auth/iss-wrong-issuer
3037
- auth/iss-unexpected
3138
- auth/iss-normalized
32-
- auth/metadata-issuer-mismatch
33-
# SEP-2352 (authorization server migration): client does not re-register when
34-
# PRM authorization_servers changes.
35-
- auth/authorization-server-migration
36-
# SEP-837 (application_type during DCR): the check only fires on draft-version
37-
# runs; this draft scenario is the one place the client still hits it.
38-
- auth/offline-access-not-supported
3939

4040
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
41-
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator
42-
# counts WARNINGs as failures.
43-
- auth/scope-step-up
4441
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
4542
# client support for the token-exchange + JWT bearer flow.
4643
- auth/enterprise-managed-authorization

.github/actions/conformance/run-server.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,5 @@ done
4747

4848
echo "Server ready at $SERVER_URL"
4949

50-
npx --yes @modelcontextprotocol/conformance@"${CONFORMANCE_VERSION:?set CONFORMANCE_VERSION (pinned in .github/workflows/conformance.yml)}" \
50+
npx --yes "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" \
5151
server --url "$SERVER_URL" "$@"

.github/workflows/conformance.yml

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ permissions:
1414
contents: read
1515

1616
env:
17-
# Pinned conformance harness version. Bump deliberately and reconcile
18-
# both .github/actions/conformance/expected-failures*.yml files in the
19-
# same change.
20-
CONFORMANCE_VERSION: "0.2.0-alpha.4"
17+
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
18+
# Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.5.
19+
# Bump deliberately and reconcile both
20+
# .github/actions/conformance/expected-failures*.yml files in the same change.
21+
#
22+
# TODO: replace with @modelcontextprotocol/conformance@0.2.0-alpha.5 once
23+
# https://github.com/modelcontextprotocol/conformance/pull/357 publishes, and
24+
# drop CONFORMANCE_PKG_SHA256 plus the fetch-and-verify step below.
25+
CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@65fcd39"
26+
CONFORMANCE_PKG_SHA256: "9a381d7083f8be2fe7ae44efeca54530f18c61425805ddaf9cd88915efcc1574"
2127

2228
jobs:
2329
server-conformance:
@@ -33,6 +39,19 @@ jobs:
3339
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
3440
with:
3541
node-version: 24
42+
- name: Fetch and verify conformance harness
43+
# Only when CONFORMANCE_PKG is a URL: download, check the recorded
44+
# sha256, and re-point CONFORMANCE_PKG at the verified local tarball.
45+
# When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's
46+
# own integrity check applies).
47+
run: |
48+
case "$CONFORMANCE_PKG" in
49+
https://*)
50+
curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
51+
echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
52+
echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
53+
;;
54+
esac
3655
- run: uv sync --frozen --all-extras --package mcp-everything-server
3756
- name: Run server conformance (active suite)
3857
run: >-
@@ -64,16 +83,25 @@ jobs:
6483
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
6584
with:
6685
node-version: 24
86+
- name: Fetch and verify conformance harness
87+
run: |
88+
case "$CONFORMANCE_PKG" in
89+
https://*)
90+
curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
91+
echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
92+
echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
93+
;;
94+
esac
6795
- run: uv sync --frozen --all-extras --package mcp
6896
- name: Run client conformance (all suite)
6997
run: >-
70-
npx --yes @modelcontextprotocol/conformance@"$CONFORMANCE_VERSION" client
98+
npx --yes "$CONFORMANCE_PKG" client
7199
--command 'uv run --frozen python .github/actions/conformance/client.py'
72100
--suite all
73101
--expected-failures ./.github/actions/conformance/expected-failures.yml
74102
- name: Run client conformance (2026-07-28 wire, all suite)
75103
run: >-
76-
npx --yes @modelcontextprotocol/conformance@"$CONFORMANCE_VERSION" client
104+
npx --yes "$CONFORMANCE_PKG" client
77105
--command 'uv run --frozen python .github/actions/conformance/client.py'
78106
--suite all
79107
--spec-version 2026-07-28

README.v2.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2323,7 +2323,7 @@ import httpx
23232323
from pydantic import AnyUrl
23242324

23252325
from mcp import ClientSession
2326-
from mcp.client.auth import OAuthClientProvider, TokenStorage
2326+
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
23272327
from mcp.client.streamable_http import streamable_http_client
23282328
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
23292329

@@ -2356,10 +2356,14 @@ async def handle_redirect(auth_url: str) -> None:
23562356
print(f"Visit: {auth_url}")
23572357

23582358

2359-
async def handle_callback() -> tuple[str, str | None]:
2359+
async def handle_callback() -> AuthorizationCodeResult:
23602360
callback_url = input("Paste callback URL: ")
23612361
params = parse_qs(urlparse(callback_url).query)
2362-
return params["code"][0], params.get("state", [None])[0]
2362+
return AuthorizationCodeResult(
2363+
code=params["code"][0],
2364+
state=params.get("state", [None])[0],
2365+
iss=params.get("iss", [None])[0],
2366+
)
23632367

23642368

23652369
async def main():

docs/migration.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@ async with http_client:
6262

6363
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
6464

65+
### OAuth `callback_handler` returns `AuthorizationCodeResult`
66+
67+
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`.
68+
69+
**Before (v1):**
70+
71+
```python
72+
async def callback_handler() -> tuple[str, str | None]:
73+
params = parse_qs(urlparse(await wait_for_redirect()).query)
74+
return params["code"][0], params.get("state", [None])[0]
75+
```
76+
77+
**After (v2):**
78+
79+
```python
80+
from mcp.client.auth import AuthorizationCodeResult
81+
82+
83+
async def callback_handler() -> AuthorizationCodeResult:
84+
params = parse_qs(urlparse(await wait_for_redirect()).query)
85+
return AuthorizationCodeResult(
86+
code=params["code"][0],
87+
state=params.get("state", [None])[0],
88+
iss=params.get("iss", [None])[0],
89+
)
90+
```
91+
92+
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.
93+
6594
### `get_session_id` callback removed from `streamable_http_client`
6695

6796
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.
@@ -1220,6 +1249,16 @@ Tasks are expected to return as a separate MCP extension in a future release.
12201249

12211250
## Bug Fixes
12221251

1252+
### OAuth metadata URLs no longer gain a trailing slash
1253+
1254+
`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set
1255+
`url_preserve_empty_path=True` (Pydantic 2.12+). A path-less URL parsed from the wire keeps its
1256+
empty path instead of acquiring a trailing slash, so e.g. an `issuer` of `https://as.example.com`
1257+
round-trips as `https://as.example.com` rather than `https://as.example.com/`. This matters for
1258+
RFC 9207 / RFC 8414 issuer comparisons, which require simple string comparison (RFC 3986 §6.2.1).
1259+
URLs constructed in Python from an already-built `AnyHttpUrl` object are unaffected (they were
1260+
normalized at construction); only values parsed from strings/JSON change.
1261+
12231262
### Lowlevel `Server`: `subscribe` capability now correctly reported
12241263

12251264
Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior.

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import httpx
2121
from mcp.client._transport import ReadStream, WriteStream
22-
from mcp.client.auth import OAuthClientProvider, TokenStorage
22+
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
2323
from mcp.client.session import ClientSession
2424
from mcp.client.sse import sse_client
2525
from mcp.client.streamable_http import streamable_http_client
@@ -69,6 +69,7 @@ def do_GET(self):
6969
if "code" in query_params:
7070
self.callback_data["authorization_code"] = query_params["code"][0]
7171
self.callback_data["state"] = query_params.get("state", [None])[0]
72+
self.callback_data["iss"] = query_params.get("iss", [None])[0]
7273
self.send_response(200)
7374
self.send_header("Content-type", "text/html")
7475
self.end_headers()
@@ -112,7 +113,7 @@ def __init__(self, port: int = 3000):
112113
self.port = port
113114
self.server = None
114115
self.thread = None
115-
self.callback_data = {"authorization_code": None, "state": None, "error": None}
116+
self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None}
116117

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

159-
def get_state(self):
160-
"""Get the received state parameter."""
160+
@property
161+
def state(self):
162+
"""The received state parameter."""
161163
return self.callback_data["state"]
162164

165+
@property
166+
def iss(self):
167+
"""The received iss parameter."""
168+
return self.callback_data["iss"]
169+
163170

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

186-
async def callback_handler() -> tuple[str, str | None]:
187-
"""Wait for OAuth callback and return auth code and state."""
193+
async def callback_handler() -> AuthorizationCodeResult:
194+
"""Wait for OAuth callback and return auth code, state, and iss."""
188195
print("⏳ Waiting for authorization callback...")
189196
try:
190197
auth_code = callback_server.wait_for_callback(timeout=300)
191-
return auth_code, callback_server.get_state()
198+
return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss)
192199
finally:
193200
callback_server.stop()
194201

examples/snippets/clients/oauth_client.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic import AnyUrl
1414

1515
from mcp import ClientSession
16-
from mcp.client.auth import OAuthClientProvider, TokenStorage
16+
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
1717
from mcp.client.streamable_http import streamable_http_client
1818
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
1919

@@ -46,10 +46,14 @@ async def handle_redirect(auth_url: str) -> None:
4646
print(f"Visit: {auth_url}")
4747

4848

49-
async def handle_callback() -> tuple[str, str | None]:
49+
async def handle_callback() -> AuthorizationCodeResult:
5050
callback_url = input("Paste callback URL: ")
5151
params = parse_qs(urlparse(callback_url).query)
52-
return params["code"][0], params.get("state", [None])[0]
52+
return AuthorizationCodeResult(
53+
code=params["code"][0],
54+
state=params.get("state", [None])[0],
55+
iss=params.get("iss", [None])[0],
56+
)
5357

5458

5559
async def main():

src/mcp/client/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
PKCEParameters,
1010
TokenStorage,
1111
)
12+
from mcp.shared.auth import AuthorizationCodeResult
1213

1314
__all__ = [
15+
"AuthorizationCodeResult",
1416
"OAuthClientProvider",
1517
"OAuthFlowError",
1618
"OAuthRegistrationError",

0 commit comments

Comments
 (0)