Jeremiah Lowin commited on
Commit
0fcdf08
·
unverified ·
2 Parent(s): 9a7c1b9cc579d4

Merge pull request #650 from jlowin/bearer

Browse files

Add basic bearer auth for server and client

pyproject.toml CHANGED
@@ -45,12 +45,14 @@ dev = [
45
  "ipython>=8.12.3",
46
  "pdbpp>=0.10.3",
47
  "pre-commit",
 
48
  "pyright>=1.1.389",
49
  "pytest>=8.3.3",
50
  "pytest-asyncio>=0.23.5",
51
  "pytest-cov>=6.1.1",
52
  "pytest-env>=1.1.5",
53
  "pytest-flakefinder",
 
54
  "pytest-report>=0.2.1",
55
  "pytest-timeout>=2.4.0",
56
  "pytest-xdist>=3.6.1",
 
45
  "ipython>=8.12.3",
46
  "pdbpp>=0.10.3",
47
  "pre-commit",
48
+ "pyinstrument>=5.0.2",
49
  "pyright>=1.1.389",
50
  "pytest>=8.3.3",
51
  "pytest-asyncio>=0.23.5",
52
  "pytest-cov>=6.1.1",
53
  "pytest-env>=1.1.5",
54
  "pytest-flakefinder",
55
+ "pytest-httpx>=0.35.0",
56
  "pytest-report>=0.2.1",
57
  "pytest-timeout>=2.4.0",
58
  "pytest-xdist>=3.6.1",
src/fastmcp/client/auth.py CHANGED
@@ -392,3 +392,12 @@ def OAuth(
392
  )
393
 
394
  return oauth_provider
 
 
 
 
 
 
 
 
 
 
392
  )
393
 
394
  return oauth_provider
395
+
396
+
397
+ class BearerAuth(httpx.Auth):
398
+ def __init__(self, token: str):
399
+ self.token = token
400
+
401
+ def auth_flow(self, request):
402
+ request.headers["Authorization"] = f"Bearer {self.token}"
403
+ yield request
src/fastmcp/server/auth/providers/__init__.py ADDED
File without changes
src/fastmcp/server/auth/providers/bearer.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simple JWT Bearer Token validation for hosted MCP servers.
3
+
4
+ Uses RS256 (asymmetric) where your control plane signs with a private key
5
+ and hosted MCP servers validate with the corresponding public key.
6
+
7
+ Example usage:
8
+ # Static public key
9
+ provider = BearerAuthProvider(
10
+ public_key='''-----BEGIN PUBLIC KEY-----
11
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
12
+ -----END PUBLIC KEY-----''',
13
+ issuer="https://auth.yourservice.com"
14
+ )
15
+
16
+ # Or JWKS URI (recommended for production - allows key rotation)
17
+ provider = Bear(
18
+ jwks_uri="https://auth.yourservice.com/.well-known/jwks.json",
19
+ issuer="https://auth.yourservice.com"
20
+ )
21
+ """
22
+
23
+ import time
24
+ from dataclasses import dataclass
25
+ from typing import Any, TypedDict
26
+
27
+ import httpx
28
+ from authlib.jose import JsonWebKey, JsonWebToken
29
+ from authlib.jose.errors import JoseError
30
+ from cryptography.hazmat.primitives import serialization
31
+ from cryptography.hazmat.primitives.asymmetric import rsa
32
+ from mcp.server.auth.provider import (
33
+ AccessToken,
34
+ AuthorizationCode,
35
+ AuthorizationParams,
36
+ RefreshToken,
37
+ )
38
+ from mcp.shared.auth import (
39
+ OAuthClientInformationFull,
40
+ OAuthToken,
41
+ )
42
+ from pydantic import SecretStr
43
+
44
+ from fastmcp.server.auth.auth import (
45
+ ClientRegistrationOptions,
46
+ OAuthProvider,
47
+ RevocationOptions,
48
+ )
49
+
50
+
51
+ class JWKData(TypedDict, total=False):
52
+ """JSON Web Key data structure."""
53
+
54
+ kty: str # Key type (e.g., "RSA") - required
55
+ kid: str # Key ID (optional but recommended)
56
+ use: str # Usage (e.g., "sig")
57
+ alg: str # Algorithm (e.g., "RS256")
58
+ n: str # Modulus (for RSA keys)
59
+ e: str # Exponent (for RSA keys)
60
+ x5c: list[str] # X.509 certificate chain (for JWKs)
61
+ x5t: str # X.509 certificate thumbprint (for JWKs)
62
+
63
+
64
+ class JWKSData(TypedDict):
65
+ """JSON Web Key Set data structure."""
66
+
67
+ keys: list[JWKData]
68
+
69
+
70
+ @dataclass(frozen=True, kw_only=True, repr=False)
71
+ class RSAKeyPair:
72
+ private_key: SecretStr
73
+ public_key: str
74
+
75
+ @classmethod
76
+ def generate(cls) -> "RSAKeyPair":
77
+ """
78
+ Generate an RSA key pair for testing.
79
+
80
+ Returns:
81
+ tuple: (private_key_pem, public_key_pem)
82
+ """
83
+ # Generate private key
84
+ private_key = rsa.generate_private_key(
85
+ public_exponent=65537,
86
+ key_size=2048,
87
+ )
88
+
89
+ # Get public key
90
+ public_key = private_key.public_key()
91
+
92
+ # Serialize private key to PEM format
93
+ private_pem = private_key.private_bytes(
94
+ encoding=serialization.Encoding.PEM,
95
+ format=serialization.PrivateFormat.PKCS8,
96
+ encryption_algorithm=serialization.NoEncryption(),
97
+ ).decode("utf-8")
98
+
99
+ # Serialize public key to PEM format
100
+ public_pem = public_key.public_bytes(
101
+ encoding=serialization.Encoding.PEM,
102
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
103
+ ).decode("utf-8")
104
+
105
+ return cls(
106
+ private_key=SecretStr(private_pem),
107
+ public_key=public_pem,
108
+ )
109
+
110
+ def create_token(
111
+ self,
112
+ subject: str = "fastmcp-user",
113
+ issuer: str = "https://fastmcp.example.com",
114
+ audience: str | None = None,
115
+ scopes: list[str] | None = None,
116
+ expires_in_seconds: int = 3600,
117
+ additional_claims: dict[str, Any] | None = None,
118
+ kid: str | None = None,
119
+ ) -> str:
120
+ """
121
+ Generate a test JWT token for testing purposes.
122
+
123
+ Args:
124
+ private_key_pem: RSA private key in PEM format
125
+ subject: Subject claim (usually user ID)
126
+ issuer: Issuer claim
127
+ audience: Audience claim (optional)
128
+ scopes: List of scopes to include
129
+ expires_in_seconds: Token expiration time in seconds
130
+ additional_claims: Any additional claims to include
131
+ kid: Key ID for JWKS lookup (optional)
132
+
133
+ Returns:
134
+ Signed JWT token string
135
+ """
136
+ jwt = JsonWebToken(["RS256"])
137
+
138
+ now = int(time.time())
139
+
140
+ # Build payload
141
+ payload = {
142
+ "iss": issuer,
143
+ "sub": subject,
144
+ "iat": now,
145
+ "exp": now + expires_in_seconds,
146
+ }
147
+
148
+ if audience:
149
+ payload["aud"] = audience
150
+
151
+ if scopes:
152
+ payload["scope"] = " ".join(scopes)
153
+
154
+ if additional_claims:
155
+ payload.update(additional_claims)
156
+
157
+ # Create header
158
+ header = {"alg": "RS256"}
159
+ if kid:
160
+ header["kid"] = kid
161
+
162
+ # Sign and return token
163
+ token_bytes = jwt.encode(
164
+ header,
165
+ payload,
166
+ key=self.private_key.get_secret_value(),
167
+ )
168
+
169
+ return token_bytes.decode("utf-8")
170
+
171
+
172
+ class BearerAuthProvider(OAuthProvider):
173
+ """
174
+ Simple JWT Bearer Token validator for hosted MCP servers.
175
+ Uses RS256 asymmetric encryption. Supports either static public key
176
+ or JWKS URI for key rotation.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ issuer: str | None = None,
182
+ public_key: str | None = None,
183
+ jwks_uri: str | None = None,
184
+ audience: str | None = None,
185
+ required_scopes: list[str] | None = None,
186
+ ):
187
+ """
188
+ Initialize the provider.
189
+
190
+ Args:
191
+ issuer: Expected issuer claim (your control plane)
192
+ public_key: RSA public key in PEM format (for static key)
193
+ jwks_uri: URI to fetch keys from (for key rotation)
194
+ audience: Expected audience claim (optional)
195
+ required_scopes: List of required scopes for access
196
+ """
197
+ if not (public_key or jwks_uri):
198
+ raise ValueError("Either public_key or jwks_uri must be provided")
199
+ if public_key and jwks_uri:
200
+ raise ValueError("Provide either public_key or jwks_uri, not both")
201
+
202
+ super().__init__(
203
+ issuer_url=issuer or "https://fastmcp.example.com",
204
+ client_registration_options=ClientRegistrationOptions(enabled=False),
205
+ revocation_options=RevocationOptions(enabled=False),
206
+ required_scopes=required_scopes,
207
+ )
208
+
209
+ self.issuer = issuer
210
+ self.audience = audience
211
+ self.public_key = public_key
212
+ self.jwks_uri = jwks_uri
213
+ self.jwt = JsonWebToken(["RS256"])
214
+
215
+ # Simple JWKS cache
216
+ self._jwks_cache: dict[str, str] = {}
217
+ self._jwks_cache_time: float = 0
218
+ self._cache_ttl = 3600 # 1 hour
219
+
220
+ async def _get_verification_key(self, token: str) -> str:
221
+ """Get the verification key for the token."""
222
+ if self.public_key:
223
+ return self.public_key
224
+
225
+ # Extract kid from token header for JWKS lookup
226
+ try:
227
+ import base64
228
+ import json
229
+
230
+ header_b64 = token.split(".")[0]
231
+ header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
232
+ header = json.loads(base64.urlsafe_b64decode(header_b64))
233
+ kid = header.get("kid")
234
+
235
+ return await self._get_jwks_key(kid)
236
+
237
+ except Exception as e:
238
+ raise ValueError(f"Failed to extract key ID from token: {e}")
239
+
240
+ async def _get_jwks_key(self, kid: str | None) -> str:
241
+ """Fetch key from JWKS with simple caching."""
242
+ if not self.jwks_uri:
243
+ raise ValueError("JWKS URI not configured")
244
+
245
+ current_time = time.time()
246
+
247
+ # Check cache first
248
+ if current_time - self._jwks_cache_time < self._cache_ttl:
249
+ if kid and kid in self._jwks_cache:
250
+ return self._jwks_cache[kid]
251
+ elif not kid and len(self._jwks_cache) == 1:
252
+ # If no kid but only one key cached, use it
253
+ return next(iter(self._jwks_cache.values()))
254
+
255
+ # Fetch JWKS
256
+ try:
257
+ async with httpx.AsyncClient() as client:
258
+ response = await client.get(self.jwks_uri)
259
+ response.raise_for_status()
260
+ jwks_data = response.json()
261
+
262
+ # Cache all keys
263
+ self._jwks_cache = {}
264
+ for key_data in jwks_data.get("keys", []):
265
+ key_kid = key_data.get("kid")
266
+ jwk = JsonWebKey.import_key(key_data)
267
+ public_key = jwk.get_public_key() # type: ignore
268
+
269
+ if key_kid:
270
+ self._jwks_cache[key_kid] = public_key
271
+ else:
272
+ # Key without kid - use a default identifier
273
+ self._jwks_cache["_default"] = public_key
274
+
275
+ self._jwks_cache_time = current_time
276
+
277
+ # Select the appropriate key
278
+ if kid:
279
+ if kid not in self._jwks_cache:
280
+ raise ValueError(f"Key ID '{kid}' not found in JWKS")
281
+ return self._jwks_cache[kid]
282
+ else:
283
+ # No kid in token - only allow if there's exactly one key
284
+ if len(self._jwks_cache) == 1:
285
+ return next(iter(self._jwks_cache.values()))
286
+ elif len(self._jwks_cache) > 1:
287
+ raise ValueError(
288
+ "Multiple keys in JWKS but no key ID (kid) in token"
289
+ )
290
+ else:
291
+ raise ValueError("No keys found in JWKS")
292
+
293
+ except Exception as e:
294
+ raise ValueError(f"Failed to fetch JWKS: {e}")
295
+
296
+ async def load_access_token(self, token: str) -> AccessToken | None:
297
+ """
298
+ Validates the provided JWT bearer token.
299
+
300
+ Args:
301
+ token: The JWT token string to validate
302
+
303
+ Returns:
304
+ AccessToken object if valid, None if invalid or expired
305
+ """
306
+ try:
307
+ # Get verification key (static or from JWKS)
308
+ verification_key = await self._get_verification_key(token)
309
+
310
+ # Decode and verify the JWT token
311
+ claims = self.jwt.decode(token, verification_key)
312
+
313
+ # Validate expiration
314
+ exp = claims.get("exp")
315
+ if exp and exp < time.time():
316
+ return None
317
+
318
+ # Validate issuer
319
+ if self.issuer:
320
+ if claims.get("iss") != self.issuer:
321
+ return None
322
+
323
+ # Validate audience if configured
324
+ if self.audience:
325
+ aud = claims.get("aud")
326
+ if isinstance(aud, list):
327
+ if self.audience not in aud:
328
+ return None
329
+ elif aud != self.audience:
330
+ return None
331
+
332
+ # Extract claims - prefer client_id over sub for OAuth application identification
333
+ client_id = claims.get("client_id") or claims.get("sub") or "unknown"
334
+ scopes = self._extract_scopes(claims)
335
+
336
+ return AccessToken(
337
+ token=token,
338
+ client_id=str(client_id),
339
+ scopes=scopes,
340
+ expires_at=int(exp) if exp else None,
341
+ )
342
+
343
+ except JoseError:
344
+ return None
345
+ except Exception:
346
+ return None
347
+
348
+ def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
349
+ """Extract scopes from JWT claims."""
350
+ scope_claim = claims.get("scope", "")
351
+ if isinstance(scope_claim, str):
352
+ return scope_claim.split()
353
+ elif isinstance(scope_claim, list):
354
+ return scope_claim
355
+ return []
356
+
357
+ # --- Unused OAuth server methods ---
358
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
359
+ raise NotImplementedError("Client management not supported")
360
+
361
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
362
+ raise NotImplementedError("Client registration not supported")
363
+
364
+ async def authorize(
365
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
366
+ ) -> str:
367
+ raise NotImplementedError("Authorization flow not supported")
368
+
369
+ async def load_authorization_code(
370
+ self, client: OAuthClientInformationFull, authorization_code: str
371
+ ) -> AuthorizationCode | None:
372
+ raise NotImplementedError("Authorization code flow not supported")
373
+
374
+ async def exchange_authorization_code(
375
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
376
+ ) -> OAuthToken:
377
+ raise NotImplementedError("Authorization code exchange not supported")
378
+
379
+ async def load_refresh_token(
380
+ self, client: OAuthClientInformationFull, refresh_token: str
381
+ ) -> RefreshToken | None:
382
+ raise NotImplementedError("Refresh token flow not supported")
383
+
384
+ async def exchange_refresh_token(
385
+ self,
386
+ client: OAuthClientInformationFull,
387
+ refresh_token: RefreshToken,
388
+ scopes: list[str],
389
+ ) -> OAuthToken:
390
+ raise NotImplementedError("Refresh token exchange not supported")
391
+
392
+ async def revoke_token(
393
+ self,
394
+ token: AccessToken | RefreshToken,
395
+ ) -> None:
396
+ raise NotImplementedError("Token revocation not supported")
src/fastmcp/server/auth/{in_memory_provider.py → providers/in_memory.py} RENAMED
@@ -1,3 +1,8 @@
 
 
 
 
 
1
  import secrets
2
  import time
3
 
@@ -43,7 +48,7 @@ class InMemoryOAuthProvider(OAuthProvider):
43
  required_scopes: list[str] | None = None,
44
  ):
45
  super().__init__(
46
- issuer_url or "https://example.com",
47
  service_documentation_url=service_documentation_url,
48
  client_registration_options=client_registration_options,
49
  revocation_options=revocation_options,
 
1
+ """
2
+ This is a simple in-memory OAuth provider for testing purposes.
3
+ It simulates the OAuth 2.0 flow locally without external calls.
4
+ """
5
+
6
  import secrets
7
  import time
8
 
 
48
  required_scopes: list[str] | None = None,
49
  ):
50
  super().__init__(
51
+ issuer_url=issuer_url or "http://fastmcp.example.com",
52
  service_documentation_url=service_documentation_url,
53
  client_registration_options=client_registration_options,
54
  revocation_options=revocation_options,
src/fastmcp/server/dependencies.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
 
3
  from typing import TYPE_CHECKING, ParamSpec, TypeVar
4
 
 
 
5
  from starlette.requests import Request
6
 
7
  if TYPE_CHECKING:
@@ -10,6 +12,14 @@ if TYPE_CHECKING:
10
  P = ParamSpec("P")
11
  R = TypeVar("R")
12
 
 
 
 
 
 
 
 
 
13
 
14
  # --- Context ---
15
 
 
2
 
3
  from typing import TYPE_CHECKING, ParamSpec, TypeVar
4
 
5
+ from mcp.server.auth.middleware.auth_context import get_access_token
6
+ from mcp.server.auth.provider import AccessToken
7
  from starlette.requests import Request
8
 
9
  if TYPE_CHECKING:
 
12
  P = ParamSpec("P")
13
  R = TypeVar("R")
14
 
15
+ __all__ = [
16
+ "get_context",
17
+ "get_http_request",
18
+ "get_http_headers",
19
+ "get_access_token",
20
+ "AccessToken",
21
+ ]
22
+
23
 
24
  # --- Context ---
25
 
tests/auth/providers/test_bearer.py ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Generator
2
+ from typing import Any
3
+
4
+ import httpx
5
+ import pytest
6
+ from pytest_httpx import HTTPXMock
7
+
8
+ from fastmcp import Client, FastMCP
9
+ from fastmcp.client.auth import BearerAuth
10
+ from fastmcp.server.auth.providers.bearer import (
11
+ BearerAuthProvider,
12
+ JWKData,
13
+ JWKSData,
14
+ RSAKeyPair,
15
+ )
16
+ from fastmcp.utilities.tests import run_server_in_process
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def rsa_key_pair() -> RSAKeyPair:
21
+ return RSAKeyPair.generate()
22
+
23
+
24
+ @pytest.fixture(scope="module")
25
+ def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
26
+ return rsa_key_pair.create_token(
27
+ subject="test-user",
28
+ issuer="https://test.example.com",
29
+ audience="https://api.example.com",
30
+ )
31
+
32
+
33
+ @pytest.fixture
34
+ def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
35
+ return BearerAuthProvider(
36
+ public_key=rsa_key_pair.public_key,
37
+ issuer="https://test.example.com",
38
+ audience="https://api.example.com",
39
+ )
40
+
41
+
42
+ def run_mcp_server(
43
+ public_key: str,
44
+ host: str,
45
+ port: int,
46
+ auth_kwargs: dict[str, Any] | None = None,
47
+ run_kwargs: dict[str, Any] | None = None,
48
+ ) -> None:
49
+ mcp = FastMCP(
50
+ auth=BearerAuthProvider(
51
+ public_key=public_key,
52
+ **auth_kwargs or {},
53
+ )
54
+ )
55
+
56
+ @mcp.tool()
57
+ def add(a: int, b: int) -> int:
58
+ return a + b
59
+
60
+ mcp.run(host=host, port=port, **run_kwargs or {})
61
+
62
+
63
+ @pytest.fixture(scope="module")
64
+ def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
65
+ with run_server_in_process(
66
+ run_mcp_server,
67
+ public_key=rsa_key_pair.public_key,
68
+ run_kwargs=dict(transport="streamable-http"),
69
+ ) as url:
70
+ yield f"{url}/mcp"
71
+
72
+
73
+ class TestRSAKeyPair:
74
+ def test_generate_key_pair(self):
75
+ """Test RSA key pair generation."""
76
+ key_pair = RSAKeyPair.generate()
77
+
78
+ assert key_pair.private_key is not None
79
+ assert key_pair.public_key is not None
80
+
81
+ # Check that keys are in PEM format
82
+ private_pem = key_pair.private_key.get_secret_value()
83
+ public_pem = key_pair.public_key
84
+
85
+ assert "-----BEGIN PRIVATE KEY-----" in private_pem
86
+ assert "-----END PRIVATE KEY-----" in private_pem
87
+ assert "-----BEGIN PUBLIC KEY-----" in public_pem
88
+ assert "-----END PUBLIC KEY-----" in public_pem
89
+
90
+ def test_create_basic_token(self, rsa_key_pair: RSAKeyPair):
91
+ """Test basic token creation."""
92
+ token = rsa_key_pair.create_token(
93
+ subject="test-user",
94
+ issuer="https://test.example.com",
95
+ )
96
+
97
+ assert isinstance(token, str)
98
+ assert len(token.split(".")) == 3 # JWT has 3 parts
99
+
100
+ def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair):
101
+ """Test token creation with scopes."""
102
+ token = rsa_key_pair.create_token(
103
+ subject="test-user",
104
+ issuer="https://test.example.com",
105
+ scopes=["read", "write"],
106
+ )
107
+
108
+ assert isinstance(token, str)
109
+ # We'll validate the scopes in the BearerToken tests
110
+
111
+
112
+ class TestBearerTokenJWKS:
113
+ """Tests for JWKS URI functionality."""
114
+
115
+ @pytest.fixture
116
+ def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
117
+ """Provider configured with JWKS URI."""
118
+ return BearerAuthProvider(
119
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
120
+ issuer="https://test.example.com",
121
+ audience="https://api.example.com",
122
+ )
123
+
124
+ @pytest.fixture
125
+ def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData:
126
+ """Create mock JWKS data from RSA key pair."""
127
+ from authlib.jose import JsonWebKey
128
+
129
+ # Create JWK from the RSA public key
130
+ jwk = JsonWebKey.import_key(rsa_key_pair.public_key) # type: ignore
131
+ jwk_data: JWKData = jwk.as_dict() # type: ignore
132
+ jwk_data["kid"] = "test-key-1"
133
+ jwk_data["alg"] = "RS256"
134
+
135
+ return {"keys": [jwk_data]}
136
+
137
+ async def test_jwks_token_validation(
138
+ self,
139
+ rsa_key_pair: RSAKeyPair,
140
+ jwks_provider: BearerAuthProvider,
141
+ mock_jwks_data: JWKSData,
142
+ httpx_mock: HTTPXMock,
143
+ ):
144
+ """Test token validation using JWKS URI."""
145
+ httpx_mock.add_response(
146
+ url="https://test.example.com/.well-known/jwks.json",
147
+ json=mock_jwks_data,
148
+ )
149
+ token = rsa_key_pair.create_token(
150
+ subject="test-user",
151
+ issuer="https://test.example.com",
152
+ audience="https://api.example.com",
153
+ )
154
+
155
+ access_token = await jwks_provider.load_access_token(token)
156
+ assert access_token is not None
157
+ assert access_token.client_id == "test-user"
158
+
159
+ async def test_jwks_token_validation_with_invalid_key(
160
+ self,
161
+ rsa_key_pair: RSAKeyPair,
162
+ jwks_provider: BearerAuthProvider,
163
+ mock_jwks_data: JWKSData,
164
+ httpx_mock: HTTPXMock,
165
+ ):
166
+ httpx_mock.add_response(
167
+ url="https://test.example.com/.well-known/jwks.json",
168
+ json=mock_jwks_data,
169
+ )
170
+ token = RSAKeyPair.generate().create_token(
171
+ subject="test-user",
172
+ issuer="https://test.example.com",
173
+ audience="https://api.example.com",
174
+ )
175
+
176
+ access_token = await jwks_provider.load_access_token(token)
177
+ assert access_token is None
178
+
179
+ async def test_jwks_token_validation_with_kid(
180
+ self,
181
+ rsa_key_pair: RSAKeyPair,
182
+ jwks_provider: BearerAuthProvider,
183
+ mock_jwks_data: JWKSData,
184
+ httpx_mock: HTTPXMock,
185
+ ):
186
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
187
+ httpx_mock.add_response(
188
+ url="https://test.example.com/.well-known/jwks.json",
189
+ json=mock_jwks_data,
190
+ )
191
+ token = rsa_key_pair.create_token(
192
+ subject="test-user",
193
+ issuer="https://test.example.com",
194
+ audience="https://api.example.com",
195
+ kid="test-key-1",
196
+ )
197
+
198
+ access_token = await jwks_provider.load_access_token(token)
199
+ assert access_token is not None
200
+ assert access_token.client_id == "test-user"
201
+
202
+ async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
203
+ self,
204
+ rsa_key_pair: RSAKeyPair,
205
+ jwks_provider: BearerAuthProvider,
206
+ mock_jwks_data: JWKSData,
207
+ httpx_mock: HTTPXMock,
208
+ ):
209
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
210
+ httpx_mock.add_response(
211
+ url="https://test.example.com/.well-known/jwks.json",
212
+ json=mock_jwks_data,
213
+ )
214
+ token = rsa_key_pair.create_token(
215
+ subject="test-user",
216
+ issuer="https://test.example.com",
217
+ audience="https://api.example.com",
218
+ )
219
+
220
+ access_token = await jwks_provider.load_access_token(token)
221
+ assert access_token is not None
222
+ assert access_token.client_id == "test-user"
223
+
224
+ async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
225
+ self,
226
+ rsa_key_pair: RSAKeyPair,
227
+ jwks_provider: BearerAuthProvider,
228
+ mock_jwks_data: JWKSData,
229
+ httpx_mock: HTTPXMock,
230
+ ):
231
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
232
+ httpx_mock.add_response(
233
+ url="https://test.example.com/.well-known/jwks.json",
234
+ json=mock_jwks_data,
235
+ )
236
+ token = rsa_key_pair.create_token(
237
+ subject="test-user",
238
+ issuer="https://test.example.com",
239
+ audience="https://api.example.com",
240
+ )
241
+
242
+ access_token = await jwks_provider.load_access_token(token)
243
+ assert access_token is not None
244
+ assert access_token.client_id == "test-user"
245
+
246
+ async def test_jwks_token_validation_with_kid_mismatch(
247
+ self,
248
+ rsa_key_pair: RSAKeyPair,
249
+ jwks_provider: BearerAuthProvider,
250
+ mock_jwks_data: JWKSData,
251
+ httpx_mock: HTTPXMock,
252
+ ):
253
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
254
+ httpx_mock.add_response(
255
+ url="https://test.example.com/.well-known/jwks.json",
256
+ json=mock_jwks_data,
257
+ )
258
+ token = rsa_key_pair.create_token(
259
+ subject="test-user",
260
+ issuer="https://test.example.com",
261
+ audience="https://api.example.com",
262
+ kid="test-key-2",
263
+ )
264
+
265
+ access_token = await jwks_provider.load_access_token(token)
266
+ assert access_token is None
267
+
268
+ async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
269
+ self,
270
+ rsa_key_pair: RSAKeyPair,
271
+ jwks_provider: BearerAuthProvider,
272
+ mock_jwks_data: JWKSData,
273
+ httpx_mock: HTTPXMock,
274
+ ):
275
+ mock_jwks_data["keys"] = [
276
+ {
277
+ "kid": "test-key-1",
278
+ "alg": "RS256",
279
+ },
280
+ {
281
+ "kid": "test-key-2",
282
+ "alg": "RS256",
283
+ },
284
+ ]
285
+
286
+ httpx_mock.add_response(
287
+ url="https://test.example.com/.well-known/jwks.json",
288
+ json=mock_jwks_data,
289
+ )
290
+ token = rsa_key_pair.create_token(
291
+ subject="test-user",
292
+ issuer="https://test.example.com",
293
+ audience="https://api.example.com",
294
+ )
295
+
296
+ access_token = await jwks_provider.load_access_token(token)
297
+ assert access_token is None
298
+
299
+
300
+ class TestBearerToken:
301
+ def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
302
+ """Test provider initialization with public key."""
303
+ provider = BearerAuthProvider(
304
+ public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
305
+ )
306
+
307
+ assert provider.issuer == "https://test.example.com"
308
+ assert provider.public_key is not None
309
+ assert provider.jwks_uri is None
310
+
311
+ def test_initialization_with_jwks_uri(self):
312
+ """Test provider initialization with JWKS URI."""
313
+ provider = BearerAuthProvider(
314
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
315
+ issuer="https://test.example.com",
316
+ )
317
+
318
+ assert provider.issuer == "https://test.example.com"
319
+ assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
320
+ assert provider.public_key is None
321
+
322
+ def test_initialization_requires_key_or_uri(self):
323
+ """Test that either public_key or jwks_uri is required."""
324
+ with pytest.raises(
325
+ ValueError, match="Either public_key or jwks_uri must be provided"
326
+ ):
327
+ BearerAuthProvider(issuer="https://test.example.com")
328
+
329
+ def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
330
+ """Test that both public_key and jwks_uri cannot be provided."""
331
+ with pytest.raises(
332
+ ValueError, match="Provide either public_key or jwks_uri, not both"
333
+ ):
334
+ BearerAuthProvider(
335
+ public_key=rsa_key_pair.public_key,
336
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
337
+ issuer="https://test.example.com",
338
+ )
339
+
340
+ async def test_valid_token_validation(
341
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
342
+ ):
343
+ """Test validation of a valid token."""
344
+ token = rsa_key_pair.create_token(
345
+ subject="test-user",
346
+ issuer="https://test.example.com",
347
+ audience="https://api.example.com",
348
+ scopes=["read", "write"],
349
+ )
350
+
351
+ access_token = await bearer_provider.load_access_token(token)
352
+
353
+ assert access_token is not None
354
+ assert access_token.client_id == "test-user"
355
+ assert "read" in access_token.scopes
356
+ assert "write" in access_token.scopes
357
+ assert access_token.expires_at is not None
358
+
359
+ async def test_expired_token_rejection(
360
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
361
+ ):
362
+ """Test rejection of expired tokens."""
363
+ token = rsa_key_pair.create_token(
364
+ subject="test-user",
365
+ issuer="https://test.example.com",
366
+ audience="https://api.example.com",
367
+ expires_in_seconds=-3600, # Expired 1 hour ago
368
+ )
369
+
370
+ access_token = await bearer_provider.load_access_token(token)
371
+ assert access_token is None
372
+
373
+ async def test_invalid_issuer_rejection(
374
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
375
+ ):
376
+ """Test rejection of tokens with invalid issuer."""
377
+ token = rsa_key_pair.create_token(
378
+ subject="test-user",
379
+ issuer="https://evil.example.com", # Wrong issuer
380
+ audience="https://api.example.com",
381
+ )
382
+
383
+ access_token = await bearer_provider.load_access_token(token)
384
+ assert access_token is None
385
+
386
+ async def test_invalid_audience_rejection(
387
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
388
+ ):
389
+ """Test rejection of tokens with invalid audience."""
390
+ token = rsa_key_pair.create_token(
391
+ subject="test-user",
392
+ issuer="https://test.example.com",
393
+ audience="https://wrong-api.example.com", # Wrong audience
394
+ )
395
+
396
+ access_token = await bearer_provider.load_access_token(token)
397
+ assert access_token is None
398
+
399
+ async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
400
+ """Test that issuer validation is skipped when provider has no issuer configured."""
401
+ provider = BearerAuthProvider(
402
+ public_key=rsa_key_pair.public_key,
403
+ issuer=None, # No issuer validation
404
+ )
405
+
406
+ token = rsa_key_pair.create_token(
407
+ subject="test-user", issuer="https://any.example.com"
408
+ )
409
+
410
+ access_token = await provider.load_access_token(token)
411
+ assert access_token is not None
412
+
413
+ async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
414
+ """Test that audience validation is skipped when provider has no audience configured."""
415
+ provider = BearerAuthProvider(
416
+ public_key=rsa_key_pair.public_key,
417
+ issuer="https://test.example.com",
418
+ audience=None, # No audience validation
419
+ )
420
+
421
+ token = rsa_key_pair.create_token(
422
+ subject="test-user",
423
+ issuer="https://test.example.com",
424
+ audience="https://any-api.example.com",
425
+ )
426
+
427
+ access_token = await provider.load_access_token(token)
428
+ assert access_token is not None
429
+
430
+ async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
431
+ """Test validation with multiple audiences in token."""
432
+ provider = BearerAuthProvider(
433
+ public_key=rsa_key_pair.public_key,
434
+ issuer="https://test.example.com",
435
+ audience="https://api.example.com",
436
+ )
437
+
438
+ token = rsa_key_pair.create_token(
439
+ subject="test-user",
440
+ issuer="https://test.example.com",
441
+ additional_claims={
442
+ "aud": ["https://api.example.com", "https://other-api.example.com"]
443
+ },
444
+ )
445
+
446
+ access_token = await provider.load_access_token(token)
447
+ assert access_token is not None
448
+
449
+ async def test_scope_extraction_string(
450
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
451
+ ):
452
+ """Test scope extraction from space-separated string."""
453
+ token = rsa_key_pair.create_token(
454
+ subject="test-user",
455
+ issuer="https://test.example.com",
456
+ audience="https://api.example.com",
457
+ scopes=["read", "write", "admin"],
458
+ )
459
+
460
+ access_token = await bearer_provider.load_access_token(token)
461
+
462
+ assert access_token is not None
463
+ assert set(access_token.scopes) == {"read", "write", "admin"}
464
+
465
+ async def test_scope_extraction_list(
466
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
467
+ ):
468
+ """Test scope extraction from list format."""
469
+ token = rsa_key_pair.create_token(
470
+ subject="test-user",
471
+ issuer="https://test.example.com",
472
+ audience="https://api.example.com",
473
+ additional_claims={"scope": ["read", "write"]}, # List format
474
+ )
475
+
476
+ access_token = await bearer_provider.load_access_token(token)
477
+
478
+ assert access_token is not None
479
+ assert set(access_token.scopes) == {"read", "write"}
480
+
481
+ async def test_no_scopes(
482
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
483
+ ):
484
+ """Test token with no scopes."""
485
+ token = rsa_key_pair.create_token(
486
+ subject="test-user",
487
+ issuer="https://test.example.com",
488
+ audience="https://api.example.com",
489
+ # No scopes
490
+ )
491
+
492
+ access_token = await bearer_provider.load_access_token(token)
493
+
494
+ assert access_token is not None
495
+ assert access_token.scopes == []
496
+
497
+ async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
498
+ """Test rejection of malformed tokens."""
499
+ malformed_tokens = [
500
+ "not.a.jwt",
501
+ "too.many.parts.here.invalid",
502
+ "invalid-token",
503
+ "",
504
+ "header.body", # Missing signature
505
+ ]
506
+
507
+ for token in malformed_tokens:
508
+ access_token = await bearer_provider.load_access_token(token)
509
+ assert access_token is None
510
+
511
+ async def test_invalid_signature_rejection(
512
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
513
+ ):
514
+ """Test rejection of tokens with invalid signatures."""
515
+ # Create a token with a different key pair
516
+ other_key_pair = RSAKeyPair.generate()
517
+ token = other_key_pair.create_token(
518
+ subject="test-user",
519
+ issuer="https://test.example.com",
520
+ audience="https://api.example.com",
521
+ )
522
+
523
+ access_token = await bearer_provider.load_access_token(token)
524
+ assert access_token is None
525
+
526
+ async def test_client_id_fallback(
527
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
528
+ ):
529
+ """Test client_id extraction with fallback logic."""
530
+ # Test with explicit client_id claim
531
+ token = rsa_key_pair.create_token(
532
+ subject="user123",
533
+ issuer="https://test.example.com",
534
+ audience="https://api.example.com",
535
+ additional_claims={"client_id": "app456"},
536
+ )
537
+
538
+ access_token = await bearer_provider.load_access_token(token)
539
+ assert access_token is not None
540
+ assert access_token.client_id == "app456" # Should prefer client_id over sub
541
+
542
+
543
+ class TestFastMCPBearerAuth:
544
+ def test_bearer_auth(self):
545
+ mcp = FastMCP(
546
+ auth=BearerAuthProvider(issuer="https://test.example.com", public_key="abc")
547
+ )
548
+ assert isinstance(mcp.auth, BearerAuthProvider)
549
+
550
+ async def test_unauthorized_access(self, mcp_server_url: str):
551
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
552
+ async with Client(mcp_server_url) as client:
553
+ tools = await client.list_tools() # noqa: F841
554
+ assert exc_info.value.response.status_code == 401
555
+ assert "tools" not in locals()
556
+
557
+ async def test_authorized_access(self, mcp_server_url: str, bearer_token):
558
+ async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
559
+ tools = await client.list_tools() # noqa: F841
560
+ assert tools
561
+
562
+ async def test_invalid_token_raises_401(self, mcp_server_url: str):
563
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
564
+ async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
565
+ tools = await client.list_tools() # noqa: F841
566
+ assert exc_info.value.response.status_code == 401
567
+ assert "tools" not in locals()
568
+
569
+ async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
570
+ token = rsa_key_pair.create_token(
571
+ subject="test-user",
572
+ issuer="https://test.example.com",
573
+ audience="https://api.example.com",
574
+ expires_in_seconds=-3600,
575
+ )
576
+
577
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
578
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
579
+ tools = await client.list_tools() # noqa: F841
580
+ assert exc_info.value.response.status_code == 401
581
+ assert "tools" not in locals()
582
+
583
+ async def test_token_with_bad_signature(self, mcp_server_url: str):
584
+ rsa_key_pair = RSAKeyPair.generate()
585
+ token = rsa_key_pair.create_token()
586
+
587
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
588
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
589
+ tools = await client.list_tools() # noqa: F841
590
+ assert exc_info.value.response.status_code == 401
591
+ assert "tools" not in locals()
592
+
593
+ async def test_token_with_insufficient_scopes(
594
+ self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
595
+ ):
596
+ token = rsa_key_pair.create_token(
597
+ subject="test-user",
598
+ issuer="https://test.example.com",
599
+ audience="https://api.example.com",
600
+ scopes=["read"],
601
+ )
602
+
603
+ with run_server_in_process(
604
+ run_mcp_server,
605
+ public_key=rsa_key_pair.public_key,
606
+ auth_kwargs=dict(required_scopes=["read", "write"]),
607
+ run_kwargs=dict(transport="streamable-http"),
608
+ ) as url:
609
+ mcp_server_url = f"{url}/mcp"
610
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
611
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
612
+ tools = await client.list_tools() # noqa: F841
613
+ assert exc_info.value.response.status_code == 403
614
+ assert "tools" not in locals()
615
+
616
+ async def test_token_with_sufficient_scopes(
617
+ self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
618
+ ):
619
+ token = rsa_key_pair.create_token(
620
+ subject="test-user",
621
+ issuer="https://test.example.com",
622
+ audience="https://api.example.com",
623
+ scopes=["read", "write"],
624
+ )
625
+
626
+ with run_server_in_process(
627
+ run_mcp_server,
628
+ public_key=rsa_key_pair.public_key,
629
+ auth_kwargs=dict(required_scopes=["read", "write"]),
630
+ run_kwargs=dict(transport="streamable-http"),
631
+ ) as url:
632
+ mcp_server_url = f"{url}/mcp"
633
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
634
+ tools = await client.list_tools()
635
+ assert tools
tests/auth/test_oauth_client.py CHANGED
@@ -9,7 +9,7 @@ import fastmcp.client.auth # Import module, not the function directly
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import StreamableHttpTransport
11
  from fastmcp.server.auth.auth import ClientRegistrationOptions
12
- from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider as InMemory
13
  from fastmcp.server.server import FastMCP
14
  from fastmcp.utilities.tests import run_server_in_process
15
 
@@ -18,7 +18,7 @@ def fastmcp_server(issuer_url: str):
18
  """Create a FastMCP server with OAuth authentication."""
19
  server = FastMCP(
20
  "TestServer",
21
- auth=InMemory(
22
  issuer_url=issuer_url,
23
  client_registration_options=ClientRegistrationOptions(enabled=True),
24
  ),
 
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import StreamableHttpTransport
11
  from fastmcp.server.auth.auth import ClientRegistrationOptions
12
+ from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
13
  from fastmcp.server.server import FastMCP
14
  from fastmcp.utilities.tests import run_server_in_process
15
 
 
18
  """Create a FastMCP server with OAuth authentication."""
19
  server = FastMCP(
20
  "TestServer",
21
+ auth=InMemoryOAuthProvider(
22
  issuer_url=issuer_url,
23
  client_registration_options=ClientRegistrationOptions(enabled=True),
24
  ),
uv.lock CHANGED
@@ -449,12 +449,14 @@ dev = [
449
  { name = "ipython", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
450
  { name = "pdbpp" },
451
  { name = "pre-commit" },
 
452
  { name = "pyright" },
453
  { name = "pytest" },
454
  { name = "pytest-asyncio" },
455
  { name = "pytest-cov" },
456
  { name = "pytest-env" },
457
  { name = "pytest-flakefinder" },
 
458
  { name = "pytest-report" },
459
  { name = "pytest-timeout" },
460
  { name = "pytest-xdist" },
@@ -482,12 +484,14 @@ dev = [
482
  { name = "ipython", specifier = ">=8.12.3" },
483
  { name = "pdbpp", specifier = ">=0.10.3" },
484
  { name = "pre-commit" },
 
485
  { name = "pyright", specifier = ">=1.1.389" },
486
  { name = "pytest", specifier = ">=8.3.3" },
487
  { name = "pytest-asyncio", specifier = ">=0.23.5" },
488
  { name = "pytest-cov", specifier = ">=6.1.1" },
489
  { name = "pytest-env", specifier = ">=1.1.5" },
490
  { name = "pytest-flakefinder" },
 
491
  { name = "pytest-report", specifier = ">=0.2.1" },
492
  { name = "pytest-timeout", specifier = ">=2.4.0" },
493
  { name = "pytest-xdist", specifier = ">=3.6.1" },
@@ -998,6 +1002,62 @@ wheels = [
998
  { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
999
  ]
1000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1001
  [[package]]
1002
  name = "pyperclip"
1003
  version = "1.9.0"
@@ -1102,6 +1162,19 @@ wheels = [
1102
  { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
1103
  ]
1104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1105
  [[package]]
1106
  name = "pytest-report"
1107
  version = "0.2.1"
 
449
  { name = "ipython", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
450
  { name = "pdbpp" },
451
  { name = "pre-commit" },
452
+ { name = "pyinstrument" },
453
  { name = "pyright" },
454
  { name = "pytest" },
455
  { name = "pytest-asyncio" },
456
  { name = "pytest-cov" },
457
  { name = "pytest-env" },
458
  { name = "pytest-flakefinder" },
459
+ { name = "pytest-httpx" },
460
  { name = "pytest-report" },
461
  { name = "pytest-timeout" },
462
  { name = "pytest-xdist" },
 
484
  { name = "ipython", specifier = ">=8.12.3" },
485
  { name = "pdbpp", specifier = ">=0.10.3" },
486
  { name = "pre-commit" },
487
+ { name = "pyinstrument", specifier = ">=5.0.2" },
488
  { name = "pyright", specifier = ">=1.1.389" },
489
  { name = "pytest", specifier = ">=8.3.3" },
490
  { name = "pytest-asyncio", specifier = ">=0.23.5" },
491
  { name = "pytest-cov", specifier = ">=6.1.1" },
492
  { name = "pytest-env", specifier = ">=1.1.5" },
493
  { name = "pytest-flakefinder" },
494
+ { name = "pytest-httpx", specifier = ">=0.35.0" },
495
  { name = "pytest-report", specifier = ">=0.2.1" },
496
  { name = "pytest-timeout", specifier = ">=2.4.0" },
497
  { name = "pytest-xdist", specifier = ">=3.6.1" },
 
1002
  { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
1003
  ]
1004
 
1005
+ [[package]]
1006
+ name = "pyinstrument"
1007
+ version = "5.0.2"
1008
+ source = { registry = "https://pypi.org/simple" }
1009
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/d0/665828770e8fcd5c50880dc83f03811f814d6260bc6a8068dca0a520e68a/pyinstrument-5.0.2.tar.gz", hash = "sha256:e466033ead16a48ffa8bedbd633b90d416fa772b3b22f61226882ace0371f5f3", size = 263930, upload-time = "2025-05-24T15:47:13.358Z" }
1010
+ wheels = [
1011
+ { url = "https://files.pythonhosted.org/packages/9c/25/f64d0be5f574d2df9ddac3e7a381863f92d8ad30170b1a9de0cf805f4318/pyinstrument-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1aeaf6b39ad40b3f03bea5fa3a9bd453a92aeb721dde29c1597f842ed9c8566a", size = 129638, upload-time = "2025-05-24T15:45:20.113Z" },
1012
+ { url = "https://files.pythonhosted.org/packages/6e/b8/bc6657f91a8d2f7cf58b0993aa4e6cf20e027b53aca65c2464a50738d711/pyinstrument-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d734bd236d00e0e7f950019c689eaba1c9dd15e355867d8926c8b18b6077b221", size = 122220, upload-time = "2025-05-24T15:45:22.4Z" },
1013
+ { url = "https://files.pythonhosted.org/packages/63/5f/9a7edf13333015a9ccfd3fcf5c75ea793fbb30b153aebf6c6ace40a607b2/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:520208a9b6c3985473aa9c3f30875ae5e78e77a81081df1d8aeb4fd8b4caf197", size = 146928, upload-time = "2025-05-24T15:45:23.802Z" },
1014
+ { url = "https://files.pythonhosted.org/packages/f2/f9/f7d7b28c9038f1a570e96c8eea2a9ffeeb3ee9e75cfc74a370554776f1a6/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75e115b759288b8d65a0bf31a34a542ae102c58ef407e0614a43e0c39d261875", size = 157136, upload-time = "2025-05-24T15:45:25.629Z" },
1015
+ { url = "https://files.pythonhosted.org/packages/db/ee/aa99f275b3c5f0f32ccd37f77cb64e57597a1f26280aec03a50d2158eab7/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:091f93e6787c485a7ddf670608c00448e858a056677fc25ce349f8e44d6a9e54", size = 144680, upload-time = "2025-05-24T15:45:27.06Z" },
1016
+ { url = "https://files.pythonhosted.org/packages/ae/77/cd7300a5e099c4ad971a647ea8fb9bd081482a9e5751479034e206cd1f69/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b07971afa2652cb4f2bdcffaef11aefa32b5384c0cfb32acf9955e96dd8df8", size = 145624, upload-time = "2025-05-24T15:45:28.517Z" },
1017
+ { url = "https://files.pythonhosted.org/packages/30/59/1957e2ca2277ecc69e247383527df331002e23940d5b0a79fc5f3b870d60/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bcb28a21b80eea5986eb5cb3180689b1d489b7c6fddf34e1f4df1f95d467ad", size = 145901, upload-time = "2025-05-24T15:45:30.365Z" },
1018
+ { url = "https://files.pythonhosted.org/packages/7e/ae/396ebdf387cde376ac4b70d52f3df07374f2501ac4c09992dadf641cd71f/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:80d28162070ff40c6d2ac7dc15b933ba20ef49e891a2e650cd2b91d30cd262b2", size = 145355, upload-time = "2025-05-24T15:45:31.859Z" },
1019
+ { url = "https://files.pythonhosted.org/packages/48/5b/fec77476a9b4a316861b29f14cd0962871ad5c54c21e41c540f7c18c950c/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c75e52a9bf76f084ba074323835cba4927ab3e572adfc96439698b097e523780", size = 145008, upload-time = "2025-05-24T15:45:33.417Z" },
1020
+ { url = "https://files.pythonhosted.org/packages/fd/75/dcd391ca2790b32e41bbd49ad33626e85eb1ce00116b273d5e1d99b3e829/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ccefdd7dd938548ada43c95b24c42ec57e258ac7994a5ec7e4cc934fa4f1743b", size = 145396, upload-time = "2025-05-24T15:45:34.915Z" },
1021
+ { url = "https://files.pythonhosted.org/packages/ca/5c/64e026ccf2c7908d10882955993e73ac35a1a77426bde2617973deeda07c/pyinstrument-5.0.2-cp310-cp310-win32.whl", hash = "sha256:6b617fb024c244738aa2f6b8c2a25853eac765360ac91062578bbbcc8e22ebfe", size = 123419, upload-time = "2025-05-24T15:45:36.276Z" },
1022
+ { url = "https://files.pythonhosted.org/packages/d5/7c/7d221db96d461c7d28897499bdad55a8ae5ded983f60743bdfbf17438c20/pyinstrument-5.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6788c8f93c1a6e0ad8d0ccde1631d17eca3839945d0fa4d506cf5d4bd7a26b77", size = 124299, upload-time = "2025-05-24T15:45:37.642Z" },
1023
+ { url = "https://files.pythonhosted.org/packages/fc/f2/b3f2416740be762fdfb052b63e1d85591682fa1d2ea6ee1b10db774f6350/pyinstrument-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0eec7a263cc1ccfb101594e13256115366338fee2a156be4172fe5315f71ec45", size = 129386, upload-time = "2025-05-24T15:45:39.429Z" },
1024
+ { url = "https://files.pythonhosted.org/packages/6b/fa/a55b0bf911041b51d2a7a0e8a3feef5ed5ddb48ff0943fc667079955c14c/pyinstrument-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddd5effefb470d7f1886dc16467501b866e3b5883cf74773f13179e718b28393", size = 122100, upload-time = "2025-05-24T15:45:41.253Z" },
1025
+ { url = "https://files.pythonhosted.org/packages/a5/e1/c42b94c795bc89d5a486ad7ef349fe3b7a8c3a4e730c09b5fa54af616a6b/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e7458a6aa4048c1703354fc8a4a3c8b59d27b1409aafb707cf339d3c0bc794c", size = 145385, upload-time = "2025-05-24T15:45:43.024Z" },
1026
+ { url = "https://files.pythonhosted.org/packages/ff/41/b511141cc336ffeac284cce7d121f05802ffea4ab2c19df8869adda49743/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2373dd699711463011ec14e4918427a777f7ab73b31ae374d960725dbd5d5a28", size = 156093, upload-time = "2025-05-24T15:45:44.755Z" },
1027
+ { url = "https://files.pythonhosted.org/packages/b2/7e/4a7bc4f1c60d4886efb7397fd5bdcc7e537d01ec7372824cd834fff967a1/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38ef498fbe71c2bbd11247b71e722290da93a367d88a5a8e0f66f6cc764c2b60", size = 143136, upload-time = "2025-05-24T15:45:46.469Z" },
1028
+ { url = "https://files.pythonhosted.org/packages/d8/69/0ac06cf609153fc5eb30ccc0071ce300a181f422836ca7ce8cd431ac3ab4/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a58a8a50f0cb3ee1c2e43ffec51bf48f48945e141feed7ccd9194917b97fe5b", size = 144077, upload-time = "2025-05-24T15:45:48.333Z" },
1029
+ { url = "https://files.pythonhosted.org/packages/e3/24/12bd82822393f708e5da8f6c0b82def3f0cbe1f4fbd72a082688c583d7fa/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad2a97c79ecf0e610df292abb5c46d01a4f99778598881d6e918650fa39801b6", size = 144545, upload-time = "2025-05-24T15:45:50.137Z" },
1030
+ { url = "https://files.pythonhosted.org/packages/c9/62/40e7511fa46247ca56734d34e2d2eb6b14390c72b155255ecd1b2288d02d/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:57ec0277042ee198eb749b76a975fe60f006cd51ea0c7ce3054c937577d19315", size = 144010, upload-time = "2025-05-24T15:45:52.256Z" },
1031
+ { url = "https://files.pythonhosted.org/packages/82/77/6d40880dc46a6243951ad7cd50a77f26f6ad126b80d803616934efccf539/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:73d34047266f27acb67218e331288c0241cf0080fe4b87dfad5596236c71abd7", size = 143746, upload-time = "2025-05-24T15:45:53.702Z" },
1032
+ { url = "https://files.pythonhosted.org/packages/9b/a2/08b056d2420199dab877c665ed45bb685863dc5b83d31b2c4311430b2bbd/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cfdc23284a8e2f27637b357c226a15d52b96608d9dde187b68dfe33a947f4908", size = 143928, upload-time = "2025-05-24T15:45:55.103Z" },
1033
+ { url = "https://files.pythonhosted.org/packages/39/a1/bab336f70cd5f798d7fa21ec92784b99d3b2df0b5c1736a64fdaa4521004/pyinstrument-5.0.2-cp311-cp311-win32.whl", hash = "sha256:3e6fa135aee6af2c608e912d8d07906bbac3c5e564d94f92721831a957297c26", size = 123395, upload-time = "2025-05-24T15:45:56.469Z" },
1034
+ { url = "https://files.pythonhosted.org/packages/f2/15/8a7ac268ffe913aa64bb42ad43315dd0fc3ac493d451a50d4431ecb736c2/pyinstrument-5.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:6317df42a98a8074ccd25af5482312ec59a1f27c05dab408eb3c7b2081242733", size = 124198, upload-time = "2025-05-24T15:45:57.814Z" },
1035
+ { url = "https://files.pythonhosted.org/packages/95/36/4afdffbc4fd77dd0155c8943101f175e701ba00cb374c5e84e64790a2a32/pyinstrument-5.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d0b680ef269b528d8dcd8151362fba9683b0ac22ffe74cc8161c33b53c65b899", size = 129527, upload-time = "2025-05-24T15:45:59.216Z" },
1036
+ { url = "https://files.pythonhosted.org/packages/96/fe/7ea5af73d65f8f22585005f6e2ce1016fb3145a8ecc1ded51f965c2e98cc/pyinstrument-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c70b50ec90ae793b74733a6fc992723c6ee27c0fcb7d99848239316ded61189", size = 122068, upload-time = "2025-05-24T15:46:01.05Z" },
1037
+ { url = "https://files.pythonhosted.org/packages/3f/d2/cf8f3b8fde3f3b6768f8407c681fb57e7b5a5bf5e7450a9fbec15164987b/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aae5f4f78515009f72393fdb271a15861534a586401383785f823cf8f60aa02", size = 146679, upload-time = "2025-05-24T15:46:02.841Z" },
1038
+ { url = "https://files.pythonhosted.org/packages/8d/e2/6c00273778596560c7033cfee34aab07da6009f32c5a4dbcc35b64700e73/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3aec8bc3d1c064ff849ca3568d6b0a7cfa0162d590a9d4d250c7118d09518b22", size = 157606, upload-time = "2025-05-24T15:46:04.551Z" },
1039
+ { url = "https://files.pythonhosted.org/packages/4c/cc/ec099f566e381f8e5db21d9523dd97b3255047813da57481ab3f45436089/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28d87fac2bc0fed802b14a26982440f36c85dc53f303530ff7665a6e470315bb", size = 144317, upload-time = "2025-05-24T15:46:05.996Z" },
1040
+ { url = "https://files.pythonhosted.org/packages/37/a7/e2e54bf6d996b3c807534dbc4fe270f373660b89871c63965d3f895c285d/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b9caac53c7eda8187ed122d4f7fcc6e3392f04c583d6d70b373351cede2b829", size = 145622, upload-time = "2025-05-24T15:46:07.334Z" },
1041
+ { url = "https://files.pythonhosted.org/packages/ef/c6/0b084ddf8d836076e04912ea83ccae0f83bf4897d0168b0fd7684efdc2a4/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8124419e8731a7bdbb9f7f885a8956806a4e9ab9dd19294f8a99e74c0bbdd327", size = 145645, upload-time = "2025-05-24T15:46:09.236Z" },
1042
+ { url = "https://files.pythonhosted.org/packages/b3/4d/3e542c5986cc30bc86c304492f4696e58dc03d1816d35c5b2cabfac1d01e/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9990d9bd05fbb4fa83f24f0a62989b8e0a3ac15ff0fa19b49348c8ef5f9db50a", size = 145619, upload-time = "2025-05-24T15:46:10.643Z" },
1043
+ { url = "https://files.pythonhosted.org/packages/a7/a7/1e4664bf5ada1cff56852d10954b1ff5a39dad17b9b98a2f27054a0c0d95/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1dc35f3d200866a43d4bc7570799a405f001591c8f19a30eb7a983a717c1e1f7", size = 145049, upload-time = "2025-05-24T15:46:12.019Z" },
1044
+ { url = "https://files.pythonhosted.org/packages/fb/59/08a5237c8d1343842ac9ed3c661dce40c450f1750128fd4789ad80539253/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a335a40d0ba1fe3658ef1a5ff2fc7a6870905828014645cb19dab5c1de379447", size = 145451, upload-time = "2025-05-24T15:46:13.49Z" },
1045
+ { url = "https://files.pythonhosted.org/packages/53/d0/321b5301e36ac1577dbf73cb49769779c41ebf72ba70a3f6f62d34df902b/pyinstrument-5.0.2-cp312-cp312-win32.whl", hash = "sha256:29e565ce85e03d2541330a8174124c1ecdb073d945962a8eb738d3b1c806ac83", size = 123491, upload-time = "2025-05-24T15:46:15.319Z" },
1046
+ { url = "https://files.pythonhosted.org/packages/ae/a6/40f05febe6ab0856b4bfa119113d550d868d94a36b501e6b9fd64379b4ba/pyinstrument-5.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:300b0cc453ffe7661d5f3ceb94cdd98996fd9118f5ff1182b5336489c7d4e45c", size = 124277, upload-time = "2025-05-24T15:46:16.693Z" },
1047
+ { url = "https://files.pythonhosted.org/packages/03/88/48654e4b8c6853f218e0506e0609060a54559500b3af5ed6ac752ac4d64f/pyinstrument-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8141a5f78b927a88de46fb2bbb17e710e41d16e161fca99991635ff7196dbd5d", size = 129528, upload-time = "2025-05-24T15:46:18.108Z" },
1048
+ { url = "https://files.pythonhosted.org/packages/92/a7/885418b733350f6c2b1d8fcca322a1eee87216a266ac516d7aefd6757ec8/pyinstrument-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:12a0095ae408dbbdd429501fd4c6a3ab51d1aeff5f31be36cc3eedc8c4870ede", size = 122072, upload-time = "2025-05-24T15:46:19.513Z" },
1049
+ { url = "https://files.pythonhosted.org/packages/a4/d5/dd0b323d2949d1a3ee0531ec6cdd66c3c69c13b9a8739aeec929a0b55fd2/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eca651d840e8e75ae5330abfc5c90f6ea4af3f78f9f0269231328305a5f9c667", size = 146874, upload-time = "2025-05-24T15:46:21.38Z" },
1050
+ { url = "https://files.pythonhosted.org/packages/aa/3b/429572b57c9ae2874e86c48db91ddcd5d619bd798f73d7d2e51b28abb08d/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:89d6ffc5459b19f1c85d4433bb9bbc8925ec04a8d7caf2694218b1f557555f23", size = 155257, upload-time = "2025-05-24T15:46:22.791Z" },
1051
+ { url = "https://files.pythonhosted.org/packages/7a/98/03cd22f68607362fd8d1ba72e6367104a9dc32bd4a0dbafc823c4e366f35/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c84845ccc5318072708dc5535b6bedd54494e92a68e282e6b97b53c1db65331", size = 144380, upload-time = "2025-05-24T15:46:24.26Z" },
1052
+ { url = "https://files.pythonhosted.org/packages/eb/c4/40d7b4be6c9620c4d9bbe9788eb9bac892f386c9bd40f1937464b2b95c09/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6511092384b5729bbbf4b35534120d2969c5fdfd4f39080badedd973676b8725", size = 145794, upload-time = "2025-05-24T15:46:25.751Z" },
1053
+ { url = "https://files.pythonhosted.org/packages/05/07/3b2084b78521d5bbbc328ca9527fb54fbf645a5e62f25169b49f7bbb0bc3/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73f08cff7a8d9714be15440046289ab1a70cbc429e09967a3a106ac61538773e", size = 145803, upload-time = "2025-05-24T15:46:27.277Z" },
1054
+ { url = "https://files.pythonhosted.org/packages/22/eb/e3ffcc8734e3d9f50b6bb750209c3ad0c4626dcc3754529741499d9f1d5c/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3905b510cdab1a8255a23fbdedcba4685245cbf814fd80f5b2005b472161d16e", size = 145763, upload-time = "2025-05-24T15:46:28.656Z" },
1055
+ { url = "https://files.pythonhosted.org/packages/c6/34/6b94945a02afced9e486e9a6b20de0edcfec543e4942dea96d745e2148ac/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cd693a616166679da529168037c294ff25746c7ae5e8b547811fb25bb26439f5", size = 145208, upload-time = "2025-05-24T15:46:30.125Z" },
1056
+ { url = "https://files.pythonhosted.org/packages/99/af/0339bbfe52de9a7df01e5a244a5fec4c228d23b1f422a55318fc6d0b9d91/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:83a1659a3bc4123c81fcddfcc86608f37bd6a951da9692766c2251500a77ac06", size = 145591, upload-time = "2025-05-24T15:46:31.556Z" },
1057
+ { url = "https://files.pythonhosted.org/packages/c8/f4/76a2c652e203c15cbc7aa3f8341e07d1ea865764b3ed9f9a97b3c4a5eda2/pyinstrument-5.0.2-cp313-cp313-win32.whl", hash = "sha256:386d047db6c043dcc86bac592873234a89eaa258460e1ad8f47a11fcc7b024d5", size = 123490, upload-time = "2025-05-24T15:46:32.951Z" },
1058
+ { url = "https://files.pythonhosted.org/packages/e4/63/14f5c6253e8c85c758485c7717f542346a0d4487818afc28721912a1574b/pyinstrument-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:971c974c061019fa6177a021882255e639399bc15bf71b0a17979830702ad8d3", size = 124287, upload-time = "2025-05-24T15:46:34.333Z" },
1059
+ ]
1060
+
1061
  [[package]]
1062
  name = "pyperclip"
1063
  version = "1.9.0"
 
1162
  { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
1163
  ]
1164
 
1165
+ [[package]]
1166
+ name = "pytest-httpx"
1167
+ version = "0.35.0"
1168
+ source = { registry = "https://pypi.org/simple" }
1169
+ dependencies = [
1170
+ { name = "httpx" },
1171
+ { name = "pytest" },
1172
+ ]
1173
+ sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" }
1174
+ wheels = [
1175
+ { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" },
1176
+ ]
1177
+
1178
  [[package]]
1179
  name = "pytest-report"
1180
  version = "0.2.1"