"""End-to-end OAuth authorization-code flow against the SDK's own server, fully in process. Auth is HTTP-only so these tests are not transport-parametrized; each connects via `connect_with_oauth`, which co-hosts the SDK's authorization server, protected-resource metadata, and bearer-gated MCP endpoint on one bridge-backed Starlette app and drives the whole flow through one `httpx.AsyncClient` carrying the SDK's `OAuthClientProvider`. The authorize redirect completes headlessly through the same bridge, so every request the flow makes is observable via `on_request`. """ import json from collections import Counter from urllib.parse import parse_qs, urlsplit import anyio import httpx import mcp_types as types import pytest from inline_snapshot import snapshot from mcp_types import CallToolResult, ListToolsResult, TextContent, Tool from pydantic import AnyUrl from mcp.server import Server, ServerRequestContext from mcp.server.auth.middleware.auth_context import get_access_token from mcp.shared.auth import OAuthClientInformationFull from tests.interaction._connect import BASE_URL from tests.interaction._requirements import requirement from tests.interaction.auth._harness import ( REDIRECT_URI, InMemoryTokenStorage, auth_settings, connect_with_oauth, oauth_client_metadata, shimmed_app, ) from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider from tests.interaction.transports._bridge import StreamingASGITransport pytestmark = pytest.mark.anyio async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: return ListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object"})]) @requirement("flow:oauth:authorization-code-roundtrip") @requirement("client-auth:401-triggers-flow") @requirement("hosting:auth:missing-401") async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow_connects() -> None: """Connecting to a bearer-gated server walks the full authorization-code flow and succeeds. Three requirements are proven by one connect: the flow runs end to end (authorization-code roundtrip), it was triggered by a 401 on the first MCP request (401-triggers-flow), and that 401 carried `resource_metadata` in `WWW-Authenticate` for discovery (missing-401). The flagship test pins the recorded request sequence so the discovery → registration → authorize → token → retry order is asserted explicitly. Steps the SDK is expected to perform: 1. POST /mcp without a token → 401 with `WWW-Authenticate: Bearer resource_metadata=...`. 2. GET the protected-resource metadata. 3. GET the authorization-server metadata. 4. POST /register (dynamic client registration). 5. GET /authorize → 302 with code+state (completed by the headless redirect). 6. POST /token (authorization-code exchange). 7. Retry POST /mcp with `Authorization: Bearer ` → succeeds. """ requests: list[httpx.Request] = [] provider = InMemoryAuthorizationServerProvider() storage = InMemoryTokenStorage() server = Server("guarded", on_list_tools=list_tools) with anyio.fail_after(5): async with connect_with_oauth(server, provider=provider, storage=storage, on_request=requests.append) as ( client, headless, ): result = await client.list_tools() assert result == snapshot(ListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object"})])) assert headless.authorize_url is not None paths = [(r.method, r.url.path) for r in requests] assert Counter(paths) == snapshot( Counter( { ("POST", "/mcp"): 4, ("GET", "/.well-known/oauth-protected-resource/mcp"): 1, ("GET", "/.well-known/oauth-authorization-server"): 1, ("POST", "/register"): 1, ("GET", "/authorize"): 1, ("POST", "/token"): 1, ("GET", "/mcp"): 1, ("DELETE", "/mcp"): 1, } ) ) assert (requests[0].method, requests[0].url.path) == ("POST", "/mcp") # The recorded Request objects are live references: the auth flow mutates the original # request's headers in place when it adds the bearer token for the retry, so the first # entry's headers cannot be used to assert "no Authorization on the first attempt". The # path multiset above proving discovery happened is the evidence the first attempt was 401. # The first PRM discovery GET carries the protocol-version header (an SDK behaviour, not a # spec requirement on discovery requests). prm_get = next(r for r in requests if r.url.path == "/.well-known/oauth-protected-resource/mcp") assert prm_get.headers.get("mcp-protocol-version") == snapshot("2026-07-28") authorize = parse_qs(urlsplit(headless.authorize_url).query) assert authorize["response_type"] == ["code"] assert authorize["code_challenge_method"] == ["S256"] assert authorize["client_id"][0] in provider.clients assert storage.tokens is not None bearer = f"Bearer {storage.tokens.access_token}" authed_mcp = [r for r in requests if r.url.path == "/mcp" and r.headers.get("authorization") == bearer] assert len(authed_mcp) > 0 assert storage.tokens.access_token in provider.access_tokens @requirement("hosting:auth:authinfo-propagates") async def test_the_access_token_reaches_the_tool_handler_via_get_access_token() -> None: """A tool handler reads the request's access token through `get_access_token()`.""" async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "whoami" token = get_access_token() assert token is not None return CallToolResult(content=[TextContent(text=" ".join(token.scopes))]) server = Server("guarded", on_list_tools=list_tools, on_call_tool=call_tool) provider = InMemoryAuthorizationServerProvider() with anyio.fail_after(5): async with connect_with_oauth(server, provider=provider) as (client, _): result = await client.call_tool("whoami", {}) assert result == snapshot(CallToolResult(content=[TextContent(text="mcp")])) @requirement("client-auth:pre-registration") async def test_a_preregistered_client_skips_registration() -> None: """A client whose storage already holds client info uses it instead of registering. The provider holds the same registration server-side so the authorize and token steps accept it; the recorded requests prove no `/register` call was made. """ requests: list[httpx.Request] = [] provider = InMemoryAuthorizationServerProvider() storage = InMemoryTokenStorage() server = Server("guarded", on_list_tools=list_tools) client_info = OAuthClientInformationFull( client_id="preregistered", client_secret="s3cret", token_endpoint_auth_method="client_secret_post", redirect_uris=[AnyUrl(REDIRECT_URI)], grant_types=["authorization_code", "refresh_token"], scope="mcp", ) await provider.register_client(client_info) storage.client_info = client_info with anyio.fail_after(5): async with connect_with_oauth(server, provider=provider, storage=storage, on_request=requests.append) as ( client, _, ): await client.list_tools() assert [r.url.path for r in requests].count("/register") == 0 assert list(provider.clients) == ["preregistered"] @requirement("client-auth:dcr") async def test_the_dcr_request_carries_the_client_metadata() -> None: """Dynamic registration sends the client's metadata and persists what the server issued. The body of the recorded `/register` POST carries the metadata the test supplied (with the scope filled in from server discovery), and the server's issued client_id and secret are persisted to storage and held by the provider. """ requests: list[httpx.Request] = [] provider = InMemoryAuthorizationServerProvider() storage = InMemoryTokenStorage() server = Server("guarded", on_list_tools=list_tools) client_metadata = oauth_client_metadata() client_metadata.software_id = "interaction-test-suite" with anyio.fail_after(5): async with connect_with_oauth( server, provider=provider, storage=storage, client_metadata=client_metadata, on_request=requests.append ) as (client, _): await client.list_tools() register = next(r for r in requests if r.url.path == "/register") assert register.headers["content-type"] == "application/json" body = json.loads(register.content) assert body == snapshot( { "redirect_uris": ["http://127.0.0.1:8000/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "mcp", "application_type": "native", "client_name": "interaction-suite", "software_id": "interaction-test-suite", } ) assert storage.client_info is not None assert storage.client_info.client_id is not None assert storage.client_info.client_secret is not None assert list(provider.clients) == [storage.client_info.client_id] async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_wrapped_app() -> None: """Harness self-test: `shimmed_app` serves canned bodies, 404s, and forwards everything else. Wraps a real auth-hosting Starlette app so the forward path is exercised against the SDK's own routing; provided here so the discovery tests can rely on the shim without each adding their own contract test. """ server = Server("bare") provider = InMemoryAuthorizationServerProvider() real_app = server.streamable_http_app(auth=auth_settings(), auth_server_provider=provider) app = shimmed_app(real_app, not_found=frozenset({"/missing"}), serve={"/override": b'{"shimmed": true}'}) async with server.session_manager.run(): async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http: served = await http.get("/override") assert served.status_code == 200 assert served.headers["content-type"] == "application/json" assert served.json() == {"shimmed": True} assert (await http.get("/missing")).status_code == 404 forwarded = await http.get("/.well-known/oauth-authorization-server") assert forwarded.status_code == 200 assert forwarded.json()["issuer"] == "http://127.0.0.1:8000/"