11"""Authorization-request, token-request, and PKCE wire-level invariants of the SDK's OAuth client.
22
3- Every test connects a real `Client` end to end via `connect_with_oauth`; the assertions are on
4- the parsed authorize URL and the recorded `/token` form body, because those wire shapes are what
5- the spec mandates and `Client` cannot observe them. The recording uses `record_requests`, which
3+ Every test connects a real `ClientSession` end to end via `connect_with_oauth`; the assertions
4+ are on the parsed authorize URL and the recorded `/token` form body, because those wire shapes
5+ are what the spec mandates and the session cannot observe them. The recording uses
6+ `record_requests`, which
67snapshots each request at send time so the auth flow's in-place header mutation on retry never
78affects what was captured for the first attempt.
89
2425from inline_snapshot import snapshot
2526from pydantic import AnyHttpUrl , AnyUrl
2627
27- from mcp import types
2828from mcp .client .auth import OAuthFlowError
29- from mcp .server import Server , ServerRequestContext
29+ from mcp .server import Server
3030from mcp .shared .auth import OAuthClientInformationFull , OAuthMetadata
31- from mcp .types import ListToolsResult , Tool
31+ from mcp .types import Tool
3232from tests .interaction ._connect import BASE_URL
3333from tests .interaction ._requirements import requirement
3434from tests .interaction .auth ._harness import (
5050ASM_PATH = "/.well-known/oauth-authorization-server"
5151
5252
53- async def list_tools (ctx : ServerRequestContext , params : types .PaginatedRequestParams | None ) -> ListToolsResult :
54- return ListToolsResult (tools = [Tool (name = "echo" , inputSchema = {"type" : "object" })])
53+ def make_guarded_server () -> Server :
54+ """Build a lowlevel `Server` exposing one `echo` tool, to mount behind the OAuth-gated MCP endpoint."""
55+ server = Server ("guarded" )
56+
57+ @server .list_tools ()
58+ async def _list_tools () -> list [Tool ]:
59+ return [Tool (name = "echo" , inputSchema = {"type" : "object" })]
60+
61+ return server
5562
5663
5764def authorize_params (authorize_url : str ) -> dict [str , str ]:
@@ -91,13 +98,12 @@ def token_request(self) -> RecordedRequest:
9198async def recorded_oauth_flow () -> AsyncIterator [RecordedFlow ]:
9299 """Run one full OAuth connect with default configuration and yield its recorded wire traffic.
93100
94- `valid_scopes` includes `offline_access` so the AS metadata advertises it and the SDK's
95- SEP-2207 auto-append (and the resulting `prompt=consent`) is exercised; `required_scopes`
101+ `valid_scopes` includes `offline_access` so the AS metadata advertises it; `required_scopes`
96102 stays at `["mcp"]` so the issued token still passes the bearer middleware.
97103 """
98104 recorded , on_request = record_requests ()
99105 provider = InMemoryAuthorizationServerProvider ()
100- server = Server ( "guarded" , on_list_tools = list_tools )
106+ server = make_guarded_server ( )
101107 settings = auth_settings (required_scopes = ["mcp" ], valid_scopes = ["mcp" , "offline_access" ])
102108
103109 with anyio .fail_after (5 ):
@@ -113,7 +119,6 @@ async def recorded_oauth_flow() -> AsyncIterator[RecordedFlow]:
113119
114120@requirement ("client-auth:pkce:s256" )
115121@requirement ("client-auth:resource-parameter" )
116- @requirement ("client-auth:authorize:offline-access-consent" )
117122async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator (
118123 recorded_oauth_flow : RecordedFlow ,
119124) -> None :
@@ -130,7 +135,6 @@ async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator(
130135 "client_id" ,
131136 "code_challenge" ,
132137 "code_challenge_method" ,
133- "prompt" ,
134138 "redirect_uri" ,
135139 "resource" ,
136140 "response_type" ,
@@ -145,9 +149,7 @@ async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator(
145149 # the stable prefix so the test does not lock in a trailing-slash decision.
146150 assert params ["resource" ].startswith (BASE_URL )
147151 assert params ["state" ] != ""
148-
149- assert params ["scope" ].split (" " ) == snapshot (["mcp" , "offline_access" ])
150- assert params ["prompt" ] == "consent"
152+ assert params ["scope" ].split (" " ) == snapshot (["mcp" ])
151153
152154
153155@requirement ("client-auth:pkce:s256" )
@@ -175,15 +177,15 @@ async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None:
175177 random tokens).
176178 """
177179 provider = InMemoryAuthorizationServerProvider ()
178- server = Server ( "guarded" , on_list_tools = list_tools )
180+ server = make_guarded_server ( )
179181 headless = HeadlessOAuth (state_override = "wrong-state" )
180182
181183 with anyio .fail_after (5 ):
182184 with pytest .RaisesGroup (
183185 pytest .RaisesExc (OAuthFlowError , match = "^State parameter mismatch:" ), flatten_subgroups = True
184186 ):
185- # Entering the connect raises during the OAuth handshake (inside `Client.__aenter__`),
186- # so an `async with` body would be unreachable; entering explicitly avoids dead code.
187+ # Entering the connect raises during the OAuth handshake (before `ClientSession.initialize`
188+ # returns), so an `async with` body would be unreachable; entering explicitly avoids dead code.
187189 await connect_with_oauth (server , provider = provider , headless = headless ).__aenter__ ()
188190
189191
@@ -238,7 +240,7 @@ async def test_a_client_with_a_secret_authenticates_the_token_request_with_http_
238240 """
239241 recorded , on_request = record_requests ()
240242 provider = InMemoryAuthorizationServerProvider ()
241- server = Server ( "guarded" , on_list_tools = list_tools )
243+ server = make_guarded_server ( )
242244
243245 client_info = OAuthClientInformationFull (
244246 client_id = "cid" ,
@@ -276,7 +278,7 @@ async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_adve
276278 """
277279 recorded , on_request = record_requests ()
278280 provider = InMemoryAuthorizationServerProvider ()
279- server = Server ( "guarded" , on_list_tools = list_tools )
281+ server = make_guarded_server ( )
280282
281283 override = OAuthMetadata (
282284 issuer = AnyHttpUrl (f"{ BASE_URL } /" ),
@@ -319,7 +321,7 @@ async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_me
319321 """
320322 recorded , on_request = record_requests ()
321323 provider = InMemoryAuthorizationServerProvider (default_scopes = ["from-header" ])
322- server = Server ( "guarded" , on_list_tools = list_tools )
324+ server = make_guarded_server ( )
323325 settings = auth_settings (required_scopes = ["from-prm" ], valid_scopes = ["from-header" , "from-prm" ])
324326 challenge = f'Bearer scope="from-header", resource_metadata="{ BASE_URL } { PRM_PATH } "'
325327
@@ -360,7 +362,7 @@ async def test_pkce_is_still_sent_when_as_metadata_omits_code_challenge_methods_
360362 serve = {ASM_PATH : override .model_dump_json (exclude_none = True ).encode ()}
361363
362364 provider = InMemoryAuthorizationServerProvider ()
363- server = Server ( "guarded" , on_list_tools = list_tools )
365+ server = make_guarded_server ( )
364366
365367 with anyio .fail_after (5 ):
366368 async with connect_with_oauth (
@@ -386,7 +388,7 @@ async def test_an_authorize_error_on_the_callback_aborts_the_flow_before_the_tok
386388 """
387389 recorded , on_request = record_requests ()
388390 provider = InMemoryAuthorizationServerProvider (deny_authorize = True )
389- server = Server ( "guarded" , on_list_tools = list_tools )
391+ server = make_guarded_server ( )
390392 headless = HeadlessOAuth ()
391393
392394 with anyio .fail_after (5 ):
0 commit comments