Skip to content

Commit 5ba2445

Browse files
committed
Complete the planned 2026-07-28 coverage: the final 27 tests
JSON Schema handling: prefixItems vocabulary enforcement, the 2020-12 default dialect (with a declared-dialect violation arm proving validation follows the tag), falsy structured content reaching the validator, and non-object outputs - plus the null structured-content divergence: a tool legitimately returning JSON null is indistinguishable from one returning nothing (the model collapses both to None and the dump drops them), so a spec-legal value raises; pinned with the fix direction recorded (absent-vs-null at the model layer, not a looser client check). MRTR edges: a retry missing a requested key is re-prompted rather than errored, unknown response keys are ignored, the resultType seam (absent means complete, input_required is never masked, unrecognized values rejected - flipping the deferred entry to a pinned divergence), and the max-tokens pass-through. Auth: refresh tokens are not reused across an AS change, CIMD documents are portable, the all-scopes single challenge, and the bundled AS registration echo dropping application_type (pinned against its ledger row). The scatter: list results are connection-independent and deterministically ordered, an empty-string cursor is a valid cursor (a 2026 rule the changelog never mentioned), cancellation stops notification delivery, SSE comment lines are ignored, legacy error codes pass through opaquely, sampling messages are not retained across rounds, multi-content reads, path-traversal rejection, and resource links in prompt content. 26 entries minted, one flipped, four divergences recorded. 946 -> 1009 cells, every node accounted; suite green three consecutive runs.
1 parent 0984e27 commit 5ba2445

15 files changed

Lines changed: 1866 additions & 20 deletions

tests/interaction/_requirements.py

Lines changed: 474 additions & 12 deletions
Large diffs are not rendered by default.

tests/interaction/auth/test_as_handlers.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
import httpx
1717
import pytest
1818
from inline_snapshot import snapshot
19+
from pydantic import AnyUrl
1920

2021
from mcp.server import Server
2122
from mcp.server.auth.provider import ProviderTokenVerifier
22-
from mcp.shared.auth import OAuthClientInformationFull
23+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2324
from tests.interaction._connect import mounted_app
2425
from tests.interaction._requirements import requirement
2526
from tests.interaction.auth._harness import REDIRECT_URI, auth_settings, oauth_client_metadata
@@ -298,3 +299,34 @@ async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
298299
info = OAuthClientInformationFull.model_validate_json(response.content)
299300
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
300301
assert info.client_id in provider.clients
302+
303+
304+
@requirement("hosting:auth:as:register-echo-application-type")
305+
async def test_register_echoes_native_for_a_client_that_registered_application_type_web(
306+
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
307+
) -> None:
308+
"""A client registering `application_type: "web"` is told `"native"` in the registration echo.
309+
310+
Pins the known gap recorded on the requirement (divergence): the registration handler's
311+
field-by-field passthrough omits `application_type`, so the model default fills the echo
312+
where RFC 7591 §3.2.1 requires the registered value -- and the SDK OAuth client adopts the
313+
echo into persisted storage, so the corruption is client-visible end to end. When the
314+
one-line passthrough fix lands this test fails: re-pin the echo to `"web"`, delete the
315+
Divergence, and add the echo assertion to
316+
`test_dcr_sends_a_consumer_set_application_type_verbatim` (test_flow.py) per the
317+
requirement's note.
318+
"""
319+
http, _ = as_app
320+
metadata = OAuthClientMetadata(
321+
client_name="interaction-suite", redirect_uris=[AnyUrl(REDIRECT_URI)], application_type="web"
322+
)
323+
324+
response = await http.post("/register", content=metadata.model_dump_json())
325+
326+
# Registration itself succeeds: the divergence is in the echo, not in acceptance.
327+
assert response.status_code == 201
328+
body = response.json()
329+
# The request carried "web" (the metadata above); the echo says "native" -- the pinned gap.
330+
assert body["application_type"] == "native"
331+
# The omission is specific to application_type, not a generally lossy echo.
332+
assert body["client_name"] == "interaction-suite"

tests/interaction/auth/test_bearer.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,37 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco
156156
assert "scope" not in parsed
157157

158158

159+
@requirement("hosting:auth:scope-403:all-scopes")
160+
async def test_a_token_missing_two_required_scopes_is_challenged_with_only_the_first() -> None:
161+
"""A token missing both required scopes is challenged for one scope per round trip.
162+
163+
The spec says servers SHOULD include all scopes required for the current operation in a
164+
single challenge; the bearer middleware instead checks required scopes in order and 403s on
165+
the first missing one, naming only that scope in `error_description` (and emitting no
166+
`scope` parameter at all -- the sibling `hosting:auth:scope-403` divergence). Pins the known
167+
gap recorded on the requirement (divergence); when the middleware aggregates, this test
168+
fails -- re-pin to a challenge naming both scopes and delete the Divergence. The two-scope
169+
app is built inline: the file's `protected` fixture is single-scope, and a two-scope deficit
170+
is this entry's distinct observable.
171+
"""
172+
settings = auth_settings(required_scopes=["mcp:read", "mcp:write"])
173+
verifier = StaticTokenVerifier(
174+
{"tok-zeroscope": AccessToken(token="tok-zeroscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE)}
175+
)
176+
177+
async with mounted_app(Server("rs"), auth=settings, token_verifier=verifier) as (http, _):
178+
response = await post_mcp(http, bearer="tok-zeroscope")
179+
180+
assert response.status_code == 403
181+
# Full-dict equality: only the FIRST missing scope (registration order) is named, and no
182+
# `scope` key appears.
183+
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
184+
"error": "insufficient_scope",
185+
"error_description": "Required scope: mcp:read",
186+
"resource_metadata": RESOURCE_METADATA_URL,
187+
}
188+
189+
159190
@requirement("hosting:auth:aud-validation")
160191
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None:
161192
"""A token whose `resource` does not match the server's resource identifier is accepted.

tests/interaction/auth/test_lifecycle.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from mcp import MCPError
2222
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
2323
from mcp.server import Server, ServerRequestContext
24-
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
24+
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, OAuthToken
2525
from tests.interaction._connect import BASE_URL
2626
from tests.interaction._requirements import requirement
2727
from tests.interaction.auth._harness import (
@@ -319,6 +319,117 @@ async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_cli
319319
assert storage.client_info.issuer == f"{BASE_URL}/"
320320

321321

322+
@requirement("client-auth:as-binding:no-token-reuse")
323+
async def test_tokens_from_the_previous_authorization_server_are_never_replayed_after_migration() -> None:
324+
"""Tokens from the previous authorization server are discarded with its credentials, never replayed.
325+
326+
Choreography twin of the as-binding discard test above, pinning the token half of the same
327+
SEP-2352 branch: storage carries both an old-issuer client registration and that server's
328+
tokens. The stale access token is presented once to the resource server (reload treats it
329+
as live), the 401 triggers the binding check, and the discard drops tokens together with
330+
the credentials -- so the stale refresh token reaches no endpoint of the new authorization
331+
server and the only token exchange is the fresh authorization-code grant. The requirement's
332+
note carries the refresh-ordering hazard this test is the regression net for.
333+
"""
334+
recorded, on_request = record_requests()
335+
provider = InMemoryAuthorizationServerProvider()
336+
# Built directly rather than via `seeded_client`: the previous authorization server no
337+
# longer exists, so its client must NOT be registered with the current provider.
338+
stale = OAuthClientInformationFull.model_validate(
339+
{
340+
"client_id": "stale-as-client",
341+
"token_endpoint_auth_method": "none",
342+
"redirect_uris": [AnyUrl(REDIRECT_URI)],
343+
"grant_types": ["authorization_code", "refresh_token"],
344+
"scope": "mcp",
345+
"issuer": "https://old-as.example.com",
346+
}
347+
)
348+
storage = InMemoryTokenStorage(client_info=stale)
349+
storage.tokens = OAuthToken(
350+
access_token="stale-access-token",
351+
token_type="Bearer",
352+
expires_in=3600,
353+
scope="mcp",
354+
refresh_token="stale-refresh-token",
355+
)
356+
server = Server("guarded", on_list_tools=list_tools)
357+
358+
with anyio.fail_after(5):
359+
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
360+
result = await client.list_tools()
361+
362+
# The only token exchange is the fresh code grant: no refresh grant ever fired.
363+
token_posts = find(recorded, "POST", "/token")
364+
assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(["authorization_code"])
365+
366+
# The MUST NOT, swept exhaustively: the stale refresh token reached no endpoint at all.
367+
for r in recorded:
368+
assert "stale-refresh-token" not in r.content.decode()
369+
assert "stale-refresh-token" not in r.url.query.decode()
370+
371+
# Scenario non-vacuity: the stale access token WAS presented and refused -- to the resource
372+
# server only, never to an authorization-server endpoint.
373+
stale_bearer_paths = [r.path for r in recorded if r.headers.get("authorization") == "Bearer stale-access-token"]
374+
assert stale_bearer_paths == ["/mcp"]
375+
376+
# The migration branch actually engaged: the discard forced one re-registration.
377+
assert path_counts(recorded)[("POST", "/register")] == 1
378+
379+
# Fresh credentials and tokens are in place, and the flow completed on them.
380+
assert result.tools[0].name == "echo"
381+
assert storage.tokens is not None
382+
assert storage.tokens.refresh_token != "stale-refresh-token"
383+
384+
385+
@requirement("client-auth:as-binding:cimd-portable")
386+
async def test_a_cimd_client_id_survives_an_authorization_server_change_without_reregistration() -> None:
387+
"""A CIMD client_id keeps working across an authorization-server change with no re-registration.
388+
389+
The spec's portability sentence: client IDs based on Client ID Metadata Documents are
390+
self-hosted HTTPS URLs resolved by the authorization server on demand, so "no
391+
re-registration is needed when the authorization server changes". Storage carries CIMD
392+
credentials stamped with the OLD issuer -- the migration precondition -- and the provider is
393+
pre-seeded with the URL client_id, the harness stand-in for on-demand resolution (the SDK
394+
server has no CIMD-aware client lookup of its own). No AS-metadata shim: the
395+
`client_id_metadata_document_supported` flag gates only the registration-path selection,
396+
which pre-seeded credentials never reach.
397+
"""
398+
recorded, on_request = record_requests()
399+
provider = InMemoryAuthorizationServerProvider()
400+
info = seeded_client(provider, client_id=CIMD_URL, issuer="https://old-as.example.com")
401+
storage = InMemoryTokenStorage(client_info=info)
402+
server = Server("guarded", on_list_tools=list_tools)
403+
404+
with anyio.fail_after(5):
405+
async with connect_with_oauth(
406+
server,
407+
provider=provider,
408+
storage=storage,
409+
client_metadata_url=CIMD_URL,
410+
on_request=on_request,
411+
) as (client, headless):
412+
result = await client.list_tools()
413+
414+
# "No re-registration is needed when the authorization server changes" -- the sentence itself.
415+
assert find(recorded, "POST", "/register") == []
416+
417+
# The SAME metadata-document URL is presented as client_id at the new authorization server.
418+
assert headless.authorize_url is not None
419+
assert authorize_params(headless.authorize_url)["client_id"] == CIMD_URL
420+
421+
# Portability is end to end, not a stalled flow: one fresh code exchange completed it.
422+
assert result.tools[0].name == "echo"
423+
assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot(["authorization_code"])
424+
425+
# The credentials survived untouched. The stale issuer stamp is informational on CIMD
426+
# credentials and deliberately not re-stamped (see the requirement's note); a future
427+
# re-stamp fails here consciously.
428+
assert storage.client_info is not None
429+
assert storage.client_info.client_id == CIMD_URL
430+
assert storage.client_info.issuer == "https://old-as.example.com"
431+
432+
322433
@requirement("client-auth:as-binding:prereg-mismatch-error")
323434
async def test_preregistered_credentials_bound_to_a_different_issuer_are_silently_replaced_without_an_error() -> None:
324435
"""Pre-registered credentials with a mismatched issuer are silently replaced rather than erroring.

tests/interaction/lowlevel/test_cancellation.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,75 @@ async def call_and_capture_error() -> None:
8787
assert errors == snapshot([ErrorData(code=0, message="Request cancelled")])
8888

8989

90+
@requirement("protocol:cancel:no-further-notifications")
91+
async def test_no_notifications_for_a_request_arrive_after_its_cancellation(connect: Connect) -> None:
92+
"""After a request is cancelled, no further notifications for it reach the wire (spec-mandated).
93+
94+
The 2026-07-28 stdio page says the receiver of a cancellation MUST NOT send any further
95+
messages for the cancelled request. The response half of "any further messages" is the
96+
divergence pinned on protocol:cancel:in-flight (both seats answer with a code-0 error); this
97+
test pins the notifications half. The handler attempts a progress send during its cancellation
98+
unwind so the negative is proved enforced, not merely unexercised: the attempt itself is
99+
cancelled before transmitting, and the caller's progress callback saw only the
100+
pre-cancellation notification.
101+
"""
102+
started = anyio.Event()
103+
handler_cancelled = anyio.Event()
104+
request_ids: list[types.RequestId] = []
105+
attempted: list[str] = []
106+
progress_updates: list[tuple[float, float | None, str | None]] = []
107+
108+
async def collect(progress: float, total: float | None, message: str | None) -> None:
109+
progress_updates.append((progress, total, message))
110+
111+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
112+
assert params.name == "block"
113+
assert ctx.request_id is not None
114+
request_ids.append(ctx.request_id)
115+
# Proves the progress channel is live before the cancellation arrives.
116+
await ctx.session.report_progress(1.0, total=2.0, message="started")
117+
started.set()
118+
try:
119+
await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
120+
except anyio.get_cancelled_exc_class():
121+
handler_cancelled.set()
122+
try:
123+
# The MUST NOT under test: a send attempted during the cancellation unwind.
124+
await ctx.session.report_progress(2.0, total=2.0, message="too late")
125+
except anyio.get_cancelled_exc_class():
126+
attempted.append("send-cancelled")
127+
raise
128+
raise NotImplementedError # unreachable: the unwind cancels the send before it transmits
129+
raise NotImplementedError # unreachable: the wait above never completes normally
130+
131+
server = Server("blocker", on_call_tool=call_tool)
132+
133+
async with connect(server) as client:
134+
with anyio.fail_after(5):
135+
async with anyio.create_task_group() as task_group:
136+
137+
async def call_and_swallow_cancellation_error() -> None:
138+
# The error shape (code 0, "Request cancelled") is protocol:cancel:in-flight's
139+
# pinned divergence and is deliberately not re-asserted here.
140+
with pytest.raises(MCPError):
141+
await client.call_tool("block", {}, progress_callback=collect)
142+
143+
task_group.start_soon(call_and_swallow_cancellation_error)
144+
await started.wait()
145+
await client.session.send_notification(
146+
types.CancelledNotification(
147+
params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted")
148+
)
149+
)
150+
151+
await handler_cancelled.wait()
152+
153+
# Request-scoped progress rides the same ordered stream as the error response that unblocked
154+
# the call, so a transmitted "too late" notification would already have been delivered.
155+
assert progress_updates == [(1.0, 2.0, "started")]
156+
assert attempted == ["send-cancelled"]
157+
158+
90159
@requirement("protocol:cancel:server-survives")
91160
async def test_session_serves_requests_after_cancellation(connect: Connect) -> None:
92161
"""A request cancelled mid-flight does not poison the session: the next request succeeds."""

0 commit comments

Comments
 (0)