-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathtest_flow.py
More file actions
240 lines (198 loc) · 10.5 KB
/
Copy pathtest_flow.py
File metadata and controls
240 lines (198 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""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 pytest
from inline_snapshot import snapshot
from pydantic import AnyUrl
from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.shared.auth import OAuthClientInformationFull
from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
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("2025-11-25")
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/"