Skip to content

Commit 7251516

Browse files
committed
backport: phase-4 waves 3+4 + S2-S9 (Workflow, 43 agents) — ~174 pass / 3 deferred
Parallel lane (14 N-risk files) + sequential lane (8 Y-risk files), all batches ≤5 tests. Deferred: roots:list-changed (no v1 lowlevel decorator), resources:templates:pagination (nullary-only), client-auth:authorize:offline-access-consent (v1 lacks SEP-2207). Several expected gaps did not materialize (InMemoryTransport, REQUEST_TIMEOUT, FastMCP ctor kwargs, verify_tokens=False) — all solvable with helpers or snapshot regen.
1 parent 1211e79 commit 7251516

22 files changed

Lines changed: 1285 additions & 1218 deletions

tests/interaction/_requirements.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,12 @@ def __post_init__(self) -> None:
794794
"A server with resource handlers advertises the resources capability, including the subscribe "
795795
"sub-flag when a subscribe handler is registered."
796796
),
797+
divergence=Divergence(
798+
note=(
799+
"The low-level Server hard-codes subscribe=False in get_capabilities() regardless of "
800+
"whether a subscribe_resource handler is registered."
801+
),
802+
),
797803
),
798804
"resources:list-changed": Requirement(
799805
source=f"{SPEC_BASE_URL}/server/resources#list-changed-notification",
@@ -857,6 +863,11 @@ def __post_init__(self) -> None:
857863
"resources:templates:pagination": Requirement(
858864
source=f"{SPEC_BASE_URL}/server/utilities/pagination#operations-supporting-pagination",
859865
behavior="resources/templates/list supports cursor pagination.",
866+
deferred=(
867+
"Not expressible via the v1 public API: Server.list_resource_templates() only accepts a nullary "
868+
"() -> list[ResourceTemplate] handler with no dual-signature dispatch, so the inbound cursor is "
869+
"unreadable and the handler cannot return a nextCursor."
870+
),
860871
),
861872
"resources:unsubscribe": Requirement(
862873
source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
@@ -1439,6 +1450,10 @@ def __post_init__(self) -> None:
14391450
"roots:list-changed": Requirement(
14401451
source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
14411452
behavior="A roots/list_changed notification sent by the client is delivered to the server's handler.",
1453+
deferred=(
1454+
"Not expressible via the v1 public API: the low-level Server exposes no decorator for "
1455+
"notifications/roots/list_changed, so a server handler cannot be registered to observe delivery."
1456+
),
14421457
),
14431458
"roots:list-changed:client-emits": Requirement(
14441459
source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
@@ -2483,6 +2498,10 @@ def __post_init__(self) -> None:
24832498
"and prompt=consent is added to the authorize request."
24842499
),
24852500
transports=("streamable-http",),
2501+
deferred=(
2502+
"Not expressible via the v1 public API: v1's OAuthClientProvider has no SEP-2207 "
2503+
"offline_access auto-append or prompt=consent logic."
2504+
),
24862505
),
24872506
"client-auth:bearer-header:every-request": Requirement(
24882507
source=f"{SPEC_BASE_URL}/basic/authorization#token-requirements",

tests/interaction/auth/test_as_handlers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,10 @@ async def test_registration_with_invalid_metadata_is_rejected_with_400(
234234
no_auth_code = await http.post("/register", json=body | {"grant_types": ["refresh_token"]})
235235
assert no_auth_code.status_code == 400
236236
assert no_auth_code.json() == snapshot(
237-
{"error": "invalid_client_metadata", "error_description": "grant_types must include 'authorization_code'"}
237+
{
238+
"error": "invalid_client_metadata",
239+
"error_description": "grant_types must be authorization_code and refresh_token",
240+
}
238241
)
239242

240243
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})

tests/interaction/auth/test_authorize_token.py

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
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
67
snapshots each request at send time so the auth flow's in-place header mutation on retry never
78
affects what was captured for the first attempt.
89
@@ -24,11 +25,10 @@
2425
from inline_snapshot import snapshot
2526
from pydantic import AnyHttpUrl, AnyUrl
2627

27-
from mcp import types
2828
from mcp.client.auth import OAuthFlowError
29-
from mcp.server import Server, ServerRequestContext
29+
from mcp.server import Server
3030
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
31-
from mcp.types import ListToolsResult, Tool
31+
from mcp.types import Tool
3232
from tests.interaction._connect import BASE_URL
3333
from tests.interaction._requirements import requirement
3434
from tests.interaction.auth._harness import (
@@ -50,8 +50,15 @@
5050
ASM_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

5764
def authorize_params(authorize_url: str) -> dict[str, str]:
@@ -91,13 +98,12 @@ def token_request(self) -> RecordedRequest:
9198
async 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")
117122
async 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):

tests/interaction/auth/test_discovery.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""Protected-resource and authorization-server metadata discovery, end to end.
22
3-
Every client-side test connects a real `Client` via `connect_with_oauth` and asserts on the
4-
recorded request paths the discovery probes produced; the discovery URL ordering is a wire
5-
detail `Client` cannot observe directly but the recording can. Tests that need a metadata
3+
Every client-side test connects a real `ClientSession` via `connect_with_oauth` and asserts on
4+
the recorded request paths the discovery probes produced; the discovery URL ordering is a wire
5+
detail the session cannot observe directly but the recording can. Tests that need a metadata
66
endpoint to 404 or return alternate content wrap the SDK's app in `shimmed_app` while leaving
77
the real authorize and token endpoints behind it, so the rest of the flow runs unaltered.
88
9-
The two server-side tests (#5, #6) drive raw httpx against `mounted_app` because their
10-
assertions are the metadata response bodies and headers, which `Client` does not surface.
9+
The two server-side tests drive raw httpx against `mounted_app` because their assertions are
10+
the metadata response bodies and headers, which `ClientSession` does not surface.
1111
"""
1212

1313
import json
@@ -17,11 +17,10 @@
1717
from inline_snapshot import snapshot
1818
from pydantic import AnyHttpUrl
1919

20-
from mcp import types
2120
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
22-
from mcp.server import Server, ServerRequestContext
21+
from mcp.server import Server
2322
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
24-
from mcp.types import ListToolsResult, Tool
23+
from mcp.types import Tool
2524
from tests.interaction._connect import BASE_URL, mounted_app
2625
from tests.interaction._requirements import requirement
2726
from tests.interaction.auth._harness import (
@@ -42,8 +41,15 @@
4241
OIDC_ROOT = "/.well-known/openid-configuration"
4342

4443

45-
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
46-
return ListToolsResult(tools=[Tool(name="probe", inputSchema={"type": "object"})])
44+
def guarded_server() -> Server:
45+
"""Build a lowlevel `Server` exposing a single `probe` tool via `list_tools`."""
46+
server = Server("guarded")
47+
48+
@server.list_tools()
49+
async def _list_tools() -> list[Tool]:
50+
return [Tool(name="probe", inputSchema={"type": "object"})]
51+
52+
return server
4753

4854

4955
def discovery_gets(recorded: list[RecordedRequest]) -> list[str]:
@@ -74,7 +80,7 @@ async def test_prm_discovery_uses_the_resource_metadata_url_from_www_authenticat
7480
"""
7581
recorded, on_request = record_requests()
7682
provider = InMemoryAuthorizationServerProvider()
77-
server = Server("guarded", on_list_tools=list_tools)
83+
server = guarded_server()
7884

7985
with anyio.fail_after(5):
8086
async with connect_with_oauth(server, provider=provider, on_request=on_request) as (client, _):
@@ -97,7 +103,7 @@ async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() ->
97103
"""
98104
recorded, on_request = record_requests()
99105
provider = InMemoryAuthorizationServerProvider()
100-
server = Server("guarded", on_list_tools=list_tools)
106+
server = guarded_server()
101107

102108
prm = ProtectedResourceMetadata(
103109
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(BASE_URL)]
@@ -132,7 +138,7 @@ async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_th
132138
"""
133139
recorded, on_request = record_requests()
134140
provider = InMemoryAuthorizationServerProvider()
135-
server = Server("guarded", on_list_tools=list_tools)
141+
server = guarded_server()
136142
app_shim = shim(not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT}))
137143

138144
with anyio.fail_after(5):
@@ -160,7 +166,7 @@ async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_e
160166
"""
161167
recorded, on_request = record_requests()
162168
provider = InMemoryAuthorizationServerProvider()
163-
server = Server("guarded", on_list_tools=list_tools)
169+
server = guarded_server()
164170
error_body = json.dumps({"error": "invalid_client_metadata", "error_description": "no"}).encode()
165171
app_shim = shim(serve={"/register": (400, error_body)})
166172

@@ -185,7 +191,7 @@ async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize()
185191
"""
186192
recorded, on_request = record_requests()
187193
provider = InMemoryAuthorizationServerProvider()
188-
server = Server("guarded", on_list_tools=list_tools)
194+
server = guarded_server()
189195

190196
prm = ProtectedResourceMetadata(
191197
resource=AnyHttpUrl(f"{BASE_URL}/other"), authorization_servers=[AnyHttpUrl(BASE_URL)]
@@ -237,7 +243,7 @@ async def test_as_metadata_discovery_falls_back_through_the_spec_endpoint_order(
237243
"""
238244
recorded, on_request = record_requests()
239245
provider = InMemoryAuthorizationServerProvider()
240-
server = Server("guarded", on_list_tools=list_tools)
246+
server = guarded_server()
241247

242248
prm = ProtectedResourceMetadata(
243249
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(authorization_server)]
@@ -320,7 +326,7 @@ async def test_as_metadata_with_a_mismatched_issuer_is_accepted_and_the_flow_pro
320326
unknown-field tolerance.
321327
"""
322328
provider = InMemoryAuthorizationServerProvider()
323-
server = Server("guarded", on_list_tools=list_tools)
329+
server = guarded_server()
324330

325331
metadata = real_asm()
326332
metadata.issuer = AnyHttpUrl(f"{BASE_URL}/wrong-issuer")

0 commit comments

Comments
 (0)