Files
modelcontextprotocol--pytho…/tests/interaction/auth/test_flow.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

241 lines
11 KiB
Python

"""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 <access_token>` → 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/"