Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
957deb2
Fix dead 2026 spec source URLs in the interaction manifest
maxisbey Jun 28, 2026
b591b58
Align interaction-manifest ids with the cross-SDK vocabulary
maxisbey Jun 28, 2026
deda5a5
Retire three redundant interaction-manifest entries; era-bound a fourth
maxisbey Jun 28, 2026
bcd1556
Make interaction-manifest behaviour strings match what their tests prove
maxisbey Jun 28, 2026
a4fd883
Model the 2025-11-25 to 2026-07-28 transition as first-class manifest…
maxisbey Jun 28, 2026
0eb3d67
Add the first MRTR interaction tests: 16 tests across six files
maxisbey Jun 28, 2026
d38927c
Complete the MRTR core coverage: multi-round, bounds, and the 2026 di…
maxisbey Jun 28, 2026
5e09c77
Cover the x-mcp-header and modern HTTP entry families: 19 tests
maxisbey Jun 28, 2026
2845766
Backlog hardening: complete the push-API divergence matrix and sharpe…
maxisbey Jun 28, 2026
70e3543
Cover the caching and discover-versioning families: 17 tests
maxisbey Jun 28, 2026
b855cc9
Cover the auth families: the RFC 9207 iss table, step-up bounds, DCR …
maxisbey Jun 28, 2026
2ce02f8
Pin the pre-registered-credentials divergence and the DCR application…
maxisbey Jun 28, 2026
f3ac09d
Track the full deferred surface: 64 entries registered ahead of their…
maxisbey Jun 28, 2026
bee0c48
Complete the planned 2026-07-28 coverage: the final 27 tests
maxisbey Jun 28, 2026
e999c40
Link the last two pinned divergences to their tracking entries
maxisbey Jun 28, 2026
48aa5a2
Re-ground the MRTR origin notes after the MCPServer pass-through landed
maxisbey Jun 29, 2026
459f69f
Trim comments and docstrings to the essentials
maxisbey Jun 30, 2026
d8e335e
Re-pin the negative-ttl and Mcp-Param mismatch tests to the landed be…
maxisbey Jul 1, 2026
3fd0aa9
Re-ground deferral premises and stale notes against the current main
maxisbey Jul 1, 2026
aaa7ac4
Rewrite the unrecognized-resultType pin onto the extension seam; simp…
maxisbey Jul 1, 2026
9f88fa9
Reconcile with the hardened dual-era stream loop
maxisbey Jul 1, 2026
fd0602f
Reconcile the interaction suite with main's httpx2 rename and re-driv…
maxisbey Jul 25, 2026
946c056
Adopt the serverInfo _meta stamp fixture across the interaction suite
maxisbey Jul 25, 2026
7e0a172
Re-ground every stale interaction-manifest premise against the curren…
maxisbey Jul 26, 2026
be9b05b
Reconcile the interaction suite manifest with main
maxisbey Jul 27, 2026
e3f1e4c
Close the register-echo divergence and reconcile the cancellation sui…
maxisbey Jul 27, 2026
0042ad8
Close the four log-delivery divergences the per-request log-level gat…
maxisbey Jul 28, 2026
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
13 changes: 8 additions & 5 deletions tests/_stamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
and the strip is strict, so a modern result that lost its stamp fails the
test instead of passing silently.

The interaction matrix does not use this function directly: its `unstamped`
fixture (tests/interaction/conftest.py) resolves per cell to this strict
Era-parametrized interaction tests go through the `unstamped` fixture
(tests/interaction/conftest.py), which resolves per cell to this strict
strip on modern cells and to a must-not-be-stamped assertion on
handshake-era cells, so one comparison line enforces both eras.
handshake-era cells, so one comparison line enforces both eras. Interaction
tests pinned to a single modern connection call this function directly
(imported as `strip_stamp` where a file also uses the fixture, so the two
never share a name).
"""

from typing import Any, Protocol, TypeVar
Expand All @@ -29,8 +32,8 @@ def unstamped(result: R) -> R:
"""Assert the result carries a well-formed serverInfo stamp, then remove it.

Returns the result for inline use in comparisons. Use only where a stamp
is required (a 2026-era result); the interaction matrix's `unstamped`
fixture handles the era split.
is required (a 2026-era result); tests that also run on handshake-era
cells use the `unstamped` fixture instead, which handles the era split.
"""
meta = result.meta
assert meta is not None and SERVER_INFO_META_KEY in meta, "expected a serverInfo stamp on this result"
Expand Down
15 changes: 11 additions & 4 deletions tests/interaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@
`async with connect(server, ...) as client:` and asserts the same output on every leg, because the
transport is not supposed to change observable behaviour. Requirements that need a server-to-client
back-channel or persisted session state are carved out of the stateless arm via `arm_exclusions`.

The 2026 cells run the client's response cache in its default-on configuration. Servers stamp
`ttlMs: 0` by default, so nothing is served from cache unless a test opts in server-side by
authoring a positive `ttl_ms` — a test that does so and then repeats a call must expect the
repeat to be served from cache instead of reaching the handler.

Tests that are tied to one transport do not use the fixture: the wire-recording tests
(their seam is the in-memory stream pair), the bare-`ClientSession` lifecycle tests, the
real-clock timeout tests (the timeout machinery is transport-independent and must not race
Expand Down Expand Up @@ -166,10 +172,11 @@
closes, grep for the reason string to find every cell to re-admit.
- **`known_failures`** keep a cell in the grid but mark it as a strict xfail — the test runs and
must fail; an unexpected pass fails the suite.
- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently only
`sse` is locked to `2025-11-25`). A `(transport, version)` cell is dropped if the version is not
in the transport's entry; transports absent from the map serve every spec version. This is the
mechanism for cutting an entire transport off from a new revision (or admitting it).
- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently
`sse` and `streamable-http-stateless` are locked to `2025-11-25`). A `(transport, version)`
cell is dropped if the version is not in the transport's entry; transports absent from the
map serve every spec version. This is the mechanism for cutting an entire transport off from
a new revision (or admitting it).

Check warning on line 179 in tests/interaction/README.md

View check run for this annotation

Claude / Claude Code Review

Stale single-era-axis prose contradicts the two-membered SPEC_VERSIONS

Two doc sentences are stale against the two-membered SPEC_VERSIONS at this PR's head: the README paragraph just below the rewritten TRANSPORT_SPEC_VERSIONS bullet still says "today's test IDs are byte-identical to before the era axis existed" (they are now all [transport-version]-suffixed), and the SpecVersion docstring in tests/interaction/_requirements.py:44-45 still says only one value is on the active axis "until the 2026-07-28 implementation lands" — resolved by this very PR. Both sentences
Comment on lines +175 to +179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Two doc sentences are stale against the two-membered SPEC_VERSIONS at this PR's head: the README paragraph just below the rewritten TRANSPORT_SPEC_VERSIONS bullet still says "today's test IDs are byte-identical to before the era axis existed" (they are now all [transport-version]-suffixed), and the SpecVersion docstring in tests/interaction/_requirements.py:44-45 still says only one value is on the active axis "until the 2026-07-28 implementation lands" — resolved by this very PR. Both sentences predate the branch (main added the second axis entry first), so this is non-blocking; two one-line rewords fix it.

Extended reasoning...

What is stale. This PR rewrites the TRANSPORT_SPEC_VERSIONS bullet in the README's "What admits or excludes a cell" section (lines 175-179) to reflect the current two-era matrix, but the paragraph eight lines below it (lines 187-189) still asserts: "Node IDs stay [transport] while len(SPEC_VERSIONS) == 1, so today's test IDs are byte-identical to before the era axis existed. They become [transport-version] the moment a second version is appended to SPEC_VERSIONS." At the PR head that "today" is false — the second version was appended long ago.\n\nWhy it is false now. tests/interaction/_requirements.py:47 has SPEC_VERSIONS = ("2025-11-25", "2026-07-28") — two members. cell_id() therefore takes its else branch and returns f"{transport}-{version}" for every cell, and the suite's own coverage gate pins the suffixed names: test_coverage.py asserts cells named in-memory-2025-11-25streamable-http-2026-07-28. Every one of the PR's 1060 collected cells carries a version suffix; none are byte-identical to the pre-era-axis ids.\n\nSecond site, same root cause. The SpecVersion docstring at tests/interaction/_requirements.py:44-45 reads: "Both values are typed even though only one is on the active axis (SPEC_VERSIONS) until the 2026-07-28 implementation lands." That sits two lines above the tuple that puts both values on the active axis — and this PR is precisely "the 2026-07-28 implementation" landing, so the docstring's "until" condition is resolved by the PR carrying it.\n\nStep-by-step proof. (1) Open tests/interaction/_requirements.py at the PR head: line 47 is SPEC_VERSIONS: tuple[SpecVersion, ...] = ("2025-11-25", "2026-07-28"). (2) cell_id() (around line 7697) checks len(SPEC_VERSIONS): with two members it emits {transport}-{version}. (3) Run any interaction test — the collected node id is e.g. test_foo[in-memory-2026-07-28], not test_foo[in-memory]. (4) Read README lines 187-189: the text says today's ids are bare [transport]. A maintainer reading the exact section this PR just edited is told current node ids are unsuffixed, then runs the suite and sees [in-memory-2026-07-28].\n\nWhy nothing prevents or catches it. These are prose-only claims: no gate, test, or runtime behaviour depends on either sentence, so the suite stays green and merging breaks nothing. But the PR's own trailing reconciliation commit ("Re-ground every stale interaction-manifest premise against the current main") exists to eliminate exactly this class of staleness, and it stopped one paragraph short in the README section it touched and left the docstring in a file it rewrote wholesale.\n\nAttribution and fix. Both stale sentences predate the branch — main put 2026-07-28 on the axis before this branch's final rebase, and the PR diff never touches SPEC_VERSIONS or the byte-identical paragraph — so this does not block merge. Fix with two one-line rewords: condition the README claim (e.g. "with two versions on the axis, ids are [transport-version]; they would collapse back to bare [transport] if the axis ever shrank to one") and update the SpecVersion docstring to say both revisions are now on the active axis.

- **`transports`** is descriptive metadata for the non-`connect` transport-specific suites under
`transports/` and does **not** drive cell generation. Only `arm_exclusions`, `added_in`,
`removed_in`, and `TRANSPORT_SPEC_VERSIONS` filter the grid.
Expand Down
4 changes: 3 additions & 1 deletion tests/interaction/_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@ def httpx_client_factory(
transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
async with Client(
transport,
# SSE is a legacy-only transport; the modern path has no SSE story.
# A policy lock, not a capability one: the dual-era server loop behind build_sse_app
# would negotiate 2026 if probed, but SSE is the deprecated legacy transport and its
# clients run the handshake era by design.
mode="legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
Expand Down
4,248 changes: 3,878 additions & 370 deletions tests/interaction/_requirements.py

Large diffs are not rendered by default.

84 changes: 75 additions & 9 deletions tests/interaction/auth/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
from typing import Any
from urllib.parse import parse_qs, parse_qsl, urlsplit

import anyio
import httpx2
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from mcp.client.auth import OAuthClientProvider
from mcp.client.client import Client
Expand Down Expand Up @@ -132,25 +133,38 @@ class HeadlessOAuth:
`redirect_handler` GETs the authorize URL on the bound client (with `auth=None` so the
request does not re-enter the locked auth flow), parses `code` and `state` from the 302
`Location`, and stashes them; `callback_handler` returns the stashed pair. Tests inspect
`authorize_url` to assert what the SDK put on the authorize request.
`authorize_url` to assert what the SDK put on the authorize request, and `iss`/`error` to
assert what the redirect carried.

`state_override`: when set, `callback_handler` returns this value as the state instead of
the one parsed from the redirect, so tests can drive the state-mismatch path.

`iss_override`: when set, `callback_handler` returns this value as the RFC 9207 issuer
instead of the one parsed from the redirect, so tests can drive the iss-mismatch path.

`code_override`: when set, returned as the code instead of the parsed one (token-endpoint rejection path).
`omit_iss`: when set, no iss is returned, overriding everything (`iss_override` cannot express absence).
"""

def __init__(self, *, state_override: str | None = None, iss_override: str | None = None) -> None:
def __init__(
self,
*,
state_override: str | None = None,
iss_override: str | None = None,
code_override: str | None = None,
omit_iss: bool = False,
) -> None:
self.authorize_url: str | None = None
self.authorize_urls: list[str] = []
self.error: str | None = None
self.iss: str | None = None
self._state_override = state_override
self._iss_override = iss_override
self._code_override = code_override
self._omit_iss = omit_iss
self._http: httpx2.AsyncClient | None = None
self._code: str = ""
self._state: str | None = None
self._iss: str | None = None

def bind(self, http_client: httpx2.AsyncClient) -> None:
self._http = http_client
Expand All @@ -166,14 +180,15 @@ async def redirect_handler(self, authorization_url: str) -> None:
params = parse_qs(urlsplit(response.headers["location"]).query)
self._code = params.get("code", [""])[0]
self._state = params.get("state", [None])[0]
self._iss = params.get("iss", [None])[0]
self.iss = params.get("iss", [None])[0]
self.error = params.get("error", [None])[0]

async def callback_handler(self) -> AuthorizationCodeResult:
iss = self._iss_override if self._iss_override is not None else self.iss
return AuthorizationCodeResult(
code=self._code,
code=self._code_override if self._code_override is not None else self._code,
state=self._state_override if self._state_override is not None else self._state,
iss=self._iss_override if self._iss_override is not None else self._iss,
iss=None if self._omit_iss else iss,
)


Expand Down Expand Up @@ -308,7 +323,7 @@ def first_challenge_shim(www_authenticate: str, *, path: str = "/mcp") -> Callab
return lambda app: _FirstChallenge(app, path, www_authenticate)


def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -> AppShim:
def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2, persist: bool = False) -> AppShim:
"""Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge.

Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up
Expand All @@ -320,6 +335,8 @@ def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -
first authenticated POST is the auth flow's retry of the original initialize request (yielded
after the 401 branch, where the generator ends without inspecting the response), so a 403
there would not reach the step-up handler.

`persist`: when set, 403s every authenticated POST from the Nth onward, re-challenging the step-up retry.
"""
seen = 0
fired = False
Expand All @@ -328,7 +345,7 @@ def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
nonlocal seen, fired
if (
not fired
(persist or not fired)
and scope["type"] == "http"
and scope["path"] == "/mcp"
and scope["method"] == "POST"
Expand All @@ -355,6 +372,55 @@ async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
return factory


def get_stream_step_up_shim(www_authenticate: str) -> tuple[list[int], anyio.Event, AppShim]:
"""Build an `app_shim` that 403s the first authenticated GET to `/mcp` with the given challenge.

Returns:
The statuses of every authenticated GET response (live-updated), an event set when one
of those responses starts with status 200 (the reopened stream), and the shim factory.
"""
statuses: list[int] = []
reopened = anyio.Event()
fired = False

def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
nonlocal fired
if not (
scope["type"] == "http"
and scope["path"] == "/mcp"
and scope["method"] == "GET"
and b"authorization" in dict(scope["headers"])
):
await app(scope, receive, send)
return

async def recording_send(message: Message) -> None:
if message["type"] == "http.response.start":
statuses.append(message["status"])
if message["status"] == 200:
reopened.set()
await send(message)

if not fired:
fired = True
await recording_send(
{
"type": "http.response.start",
"status": 403,
"headers": [(b"www-authenticate", www_authenticate.encode())],
}
)
await recording_send({"type": "http.response.body", "body": b""})
return
# The reopened SSE stream stays open until the test's exit cancels it; nothing may follow this await.
await app(scope, receive, recording_send)

return wrapped

return statuses, reopened, factory


def m2m_token_shim(provider: InMemoryAuthorizationServerProvider, *, scopes: list[str]) -> AppShim:
"""Build an `app_shim` that handles `grant_type=client_credentials` at `/token`.

Expand Down
14 changes: 13 additions & 1 deletion tests/interaction/auth/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class InMemoryAuthorizationServerProvider(
`fail_next_refresh`: the next refresh-token exchange raises `invalid_grant` once.
`reject_all_tokens`: `load_access_token` returns None for every token, so the bearer
middleware 401s every authenticated request.
`rotate_refresh_tokens`: when False, the refresh response carries no `refresh_token` and
the presented one stays valid (an RFC 6749 §6 non-rotating server).
"""

def __init__(
Expand All @@ -59,6 +61,7 @@ def __init__(
issue_expired_first: bool = False,
fail_next_refresh: bool = False,
reject_all_tokens: bool = False,
rotate_refresh_tokens: bool = True,
issuer: str | None = None,
) -> None:
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
Expand All @@ -71,6 +74,7 @@ def __init__(
self._issue_expired_first = issue_expired_first
self._fail_next_refresh = fail_next_refresh
self._reject_all_tokens = reject_all_tokens
self._rotate_refresh_tokens = rotate_refresh_tokens
self._tokens_issued = 0
self.clients: dict[str, OAuthClientInformationFull] = {}
self.codes: dict[str, AuthorizationCode] = {}
Expand Down Expand Up @@ -178,11 +182,19 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
"""Mint a new access token and rotate the refresh token, consuming the old one."""
"""Mint a new access token, and rotate the refresh token unless rotation is disabled."""
assert client.client_id is not None
if self._fail_next_refresh:
self._fail_next_refresh = False
raise TokenError(error="invalid_grant", error_description="refresh denied by harness")
if not self._rotate_refresh_tokens:
access = self.mint_access_token(client_id=client.client_id, scopes=scopes)
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
)
access = self.mint_access_token(client_id=client.client_id, scopes=scopes)
new_refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes)
Expand Down
Loading
Loading