Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
95e8d10
1
Parent(s): 9a7c1b9
Add basic bearer auth for server and client
Browse files- pyproject.toml +1 -0
- src/fastmcp/client/auth.py +9 -0
- src/fastmcp/server/auth/bearer.py +256 -0
- src/fastmcp/server/auth/providers/__init__.py +0 -0
- src/fastmcp/server/auth/providers/bearer.py +359 -0
- src/fastmcp/server/auth/{in_memory_provider.py → providers/in_memory.py} +6 -1
- src/fastmcp/server/dependencies.py +10 -0
- tests/auth/providers/test_bearer.py +366 -0
- tests/auth/test_oauth_client.py +2 -2
- uv.lock +58 -0
pyproject.toml
CHANGED
|
@@ -45,6 +45,7 @@ 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",
|
|
|
|
| 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",
|
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/bearer.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = BearerTokenValidatorProvider(
|
| 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 = BearerTokenValidatorProvider(
|
| 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 typing import Any
|
| 25 |
+
|
| 26 |
+
import httpx
|
| 27 |
+
from authlib.jose import JsonWebKey, JsonWebToken
|
| 28 |
+
from authlib.jose.errors import JoseError
|
| 29 |
+
from mcp.server.auth.provider import (
|
| 30 |
+
AccessToken,
|
| 31 |
+
AuthorizationCode,
|
| 32 |
+
AuthorizationParams,
|
| 33 |
+
RefreshToken,
|
| 34 |
+
)
|
| 35 |
+
from mcp.shared.auth import (
|
| 36 |
+
OAuthClientInformationFull,
|
| 37 |
+
OAuthToken,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
from fastmcp.server.auth.auth import (
|
| 41 |
+
ClientRegistrationOptions,
|
| 42 |
+
OAuthProvider,
|
| 43 |
+
RevocationOptions,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class BearerTokenValidatorProvider(OAuthProvider):
|
| 48 |
+
"""
|
| 49 |
+
Simple JWT Bearer Token validator for hosted MCP servers.
|
| 50 |
+
Uses RS256 asymmetric encryption. Supports either static public key
|
| 51 |
+
or JWKS URI for key rotation.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
issuer: str,
|
| 57 |
+
public_key: str | None = None,
|
| 58 |
+
jwks_uri: str | None = None,
|
| 59 |
+
audience: str | None = None,
|
| 60 |
+
required_scopes: list[str] | None = None,
|
| 61 |
+
):
|
| 62 |
+
"""
|
| 63 |
+
Initialize the provider.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
issuer: Expected issuer claim (your control plane)
|
| 67 |
+
public_key: RSA public key in PEM format (for static key)
|
| 68 |
+
jwks_uri: URI to fetch keys from (for key rotation)
|
| 69 |
+
audience: Expected audience claim (optional)
|
| 70 |
+
required_scopes: List of required scopes for access
|
| 71 |
+
"""
|
| 72 |
+
if not (public_key or jwks_uri):
|
| 73 |
+
raise ValueError("Either public_key or jwks_uri must be provided")
|
| 74 |
+
if public_key and jwks_uri:
|
| 75 |
+
raise ValueError("Provide either public_key or jwks_uri, not both")
|
| 76 |
+
|
| 77 |
+
super().__init__(
|
| 78 |
+
issuer_url=issuer,
|
| 79 |
+
client_registration_options=ClientRegistrationOptions(enabled=False),
|
| 80 |
+
revocation_options=RevocationOptions(enabled=False),
|
| 81 |
+
required_scopes=required_scopes,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
self.issuer = issuer
|
| 85 |
+
self.audience = audience
|
| 86 |
+
self.public_key = public_key
|
| 87 |
+
self.jwks_uri = jwks_uri
|
| 88 |
+
self.jwt = JsonWebToken(["RS256"])
|
| 89 |
+
|
| 90 |
+
# Simple JWKS cache
|
| 91 |
+
self._jwks_cache: dict[str, str] = {}
|
| 92 |
+
self._jwks_cache_time: float = 0
|
| 93 |
+
self._cache_ttl = 3600 # 1 hour
|
| 94 |
+
|
| 95 |
+
async def _get_verification_key(self, token: str) -> str:
|
| 96 |
+
"""Get the verification key for the token."""
|
| 97 |
+
if self.public_key:
|
| 98 |
+
return self.public_key
|
| 99 |
+
|
| 100 |
+
# Extract kid from token header for JWKS lookup
|
| 101 |
+
try:
|
| 102 |
+
import base64
|
| 103 |
+
import json
|
| 104 |
+
|
| 105 |
+
header_b64 = token.split(".")[0]
|
| 106 |
+
header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
|
| 107 |
+
header = json.loads(base64.urlsafe_b64decode(header_b64))
|
| 108 |
+
kid = header.get("kid")
|
| 109 |
+
|
| 110 |
+
if not kid:
|
| 111 |
+
raise ValueError("Token missing key ID (kid)")
|
| 112 |
+
|
| 113 |
+
return await self._get_jwks_key(kid)
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
raise ValueError(f"Failed to extract key ID from token: {e}")
|
| 117 |
+
|
| 118 |
+
async def _get_jwks_key(self, kid: str) -> str:
|
| 119 |
+
"""Fetch key from JWKS with simple caching."""
|
| 120 |
+
if not self.jwks_uri:
|
| 121 |
+
raise ValueError("JWKS URI not configured")
|
| 122 |
+
|
| 123 |
+
current_time = time.time()
|
| 124 |
+
|
| 125 |
+
# Check cache
|
| 126 |
+
if (
|
| 127 |
+
current_time - self._jwks_cache_time < self._cache_ttl
|
| 128 |
+
and kid in self._jwks_cache
|
| 129 |
+
):
|
| 130 |
+
return self._jwks_cache[kid]
|
| 131 |
+
|
| 132 |
+
# Fetch JWKS
|
| 133 |
+
try:
|
| 134 |
+
async with httpx.AsyncClient() as client:
|
| 135 |
+
response = await client.get(self.jwks_uri)
|
| 136 |
+
response.raise_for_status()
|
| 137 |
+
jwks_data = response.json()
|
| 138 |
+
|
| 139 |
+
# Cache all keys
|
| 140 |
+
self._jwks_cache = {}
|
| 141 |
+
for key_data in jwks_data.get("keys", []):
|
| 142 |
+
key_kid = key_data.get("kid")
|
| 143 |
+
if key_kid:
|
| 144 |
+
jwk = JsonWebKey.import_key(key_data)
|
| 145 |
+
self._jwks_cache[key_kid] = jwk.get_public_key()
|
| 146 |
+
|
| 147 |
+
self._jwks_cache_time = current_time
|
| 148 |
+
|
| 149 |
+
if kid not in self._jwks_cache:
|
| 150 |
+
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 151 |
+
|
| 152 |
+
return self._jwks_cache[kid]
|
| 153 |
+
|
| 154 |
+
except Exception as e:
|
| 155 |
+
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 156 |
+
|
| 157 |
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 158 |
+
"""
|
| 159 |
+
Validates the provided JWT bearer token.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
token: The JWT token string to validate
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
AccessToken object if valid, None if invalid or expired
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
# Get verification key (static or from JWKS)
|
| 169 |
+
verification_key = await self._get_verification_key(token)
|
| 170 |
+
|
| 171 |
+
# Decode and verify the JWT token
|
| 172 |
+
claims = self.jwt.decode(token, verification_key)
|
| 173 |
+
|
| 174 |
+
# Validate expiration
|
| 175 |
+
exp = claims.get("exp")
|
| 176 |
+
if exp and exp < time.time():
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
# Validate issuer
|
| 180 |
+
if claims.get("iss") != self.issuer:
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
# Validate audience if configured
|
| 184 |
+
if self.audience:
|
| 185 |
+
aud = claims.get("aud")
|
| 186 |
+
if isinstance(aud, list):
|
| 187 |
+
if self.audience not in aud:
|
| 188 |
+
return None
|
| 189 |
+
elif aud != self.audience:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
# Extract claims
|
| 193 |
+
client_id = claims.get("sub") or claims.get("client_id") or "unknown"
|
| 194 |
+
scopes = self._extract_scopes(claims)
|
| 195 |
+
|
| 196 |
+
return AccessToken(
|
| 197 |
+
token=token,
|
| 198 |
+
client_id=str(client_id),
|
| 199 |
+
scopes=scopes,
|
| 200 |
+
expires_at=int(exp) if exp else None,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
except JoseError:
|
| 204 |
+
return None
|
| 205 |
+
except Exception:
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
| 209 |
+
"""Extract scopes from JWT claims."""
|
| 210 |
+
scope_claim = claims.get("scope", "")
|
| 211 |
+
if isinstance(scope_claim, str):
|
| 212 |
+
return scope_claim.split()
|
| 213 |
+
elif isinstance(scope_claim, list):
|
| 214 |
+
return scope_claim
|
| 215 |
+
return []
|
| 216 |
+
|
| 217 |
+
# --- Unused OAuth server methods ---
|
| 218 |
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 219 |
+
raise NotImplementedError("Client management not supported")
|
| 220 |
+
|
| 221 |
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 222 |
+
raise NotImplementedError("Client registration not supported")
|
| 223 |
+
|
| 224 |
+
async def authorize(
|
| 225 |
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
| 226 |
+
) -> str:
|
| 227 |
+
raise NotImplementedError("Authorization flow not supported")
|
| 228 |
+
|
| 229 |
+
async def load_authorization_code(
|
| 230 |
+
self, client: OAuthClientInformationFull, authorization_code: str
|
| 231 |
+
) -> AuthorizationCode | None:
|
| 232 |
+
raise NotImplementedError("Authorization code flow not supported")
|
| 233 |
+
|
| 234 |
+
async def exchange_authorization_code(
|
| 235 |
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
| 236 |
+
) -> OAuthToken:
|
| 237 |
+
raise NotImplementedError("Authorization code exchange not supported")
|
| 238 |
+
|
| 239 |
+
async def load_refresh_token(
|
| 240 |
+
self, client: OAuthClientInformationFull, refresh_token: str
|
| 241 |
+
) -> RefreshToken | None:
|
| 242 |
+
raise NotImplementedError("Refresh token flow not supported")
|
| 243 |
+
|
| 244 |
+
async def exchange_refresh_token(
|
| 245 |
+
self,
|
| 246 |
+
client: OAuthClientInformationFull,
|
| 247 |
+
refresh_token: RefreshToken,
|
| 248 |
+
scopes: list[str],
|
| 249 |
+
) -> OAuthToken:
|
| 250 |
+
raise NotImplementedError("Refresh token exchange not supported")
|
| 251 |
+
|
| 252 |
+
async def revoke_token(
|
| 253 |
+
self,
|
| 254 |
+
token: AccessToken | RefreshToken,
|
| 255 |
+
) -> None:
|
| 256 |
+
raise NotImplementedError("Token revocation not supported")
|
src/fastmcp/server/auth/providers/__init__.py
ADDED
|
File without changes
|
src/fastmcp/server/auth/providers/bearer.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = BearerTokenValidatorProvider(
|
| 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 = BearerTokenValidatorProvider(
|
| 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
|
| 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 |
+
@dataclass(frozen=True, kw_only=True, repr=False)
|
| 52 |
+
class RSAKeyPair:
|
| 53 |
+
private_key: SecretStr
|
| 54 |
+
public_key: str
|
| 55 |
+
|
| 56 |
+
@classmethod
|
| 57 |
+
def generate(cls) -> "RSAKeyPair":
|
| 58 |
+
"""
|
| 59 |
+
Generate an RSA key pair for testing.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
tuple: (private_key_pem, public_key_pem)
|
| 63 |
+
"""
|
| 64 |
+
# Generate private key
|
| 65 |
+
private_key = rsa.generate_private_key(
|
| 66 |
+
public_exponent=65537,
|
| 67 |
+
key_size=2048,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Get public key
|
| 71 |
+
public_key = private_key.public_key()
|
| 72 |
+
|
| 73 |
+
# Serialize private key to PEM format
|
| 74 |
+
private_pem = private_key.private_bytes(
|
| 75 |
+
encoding=serialization.Encoding.PEM,
|
| 76 |
+
format=serialization.PrivateFormat.PKCS8,
|
| 77 |
+
encryption_algorithm=serialization.NoEncryption(),
|
| 78 |
+
).decode("utf-8")
|
| 79 |
+
|
| 80 |
+
# Serialize public key to PEM format
|
| 81 |
+
public_pem = public_key.public_bytes(
|
| 82 |
+
encoding=serialization.Encoding.PEM,
|
| 83 |
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
| 84 |
+
).decode("utf-8")
|
| 85 |
+
|
| 86 |
+
return cls(
|
| 87 |
+
private_key=SecretStr(private_pem),
|
| 88 |
+
public_key=public_pem,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def create_token(
|
| 92 |
+
self,
|
| 93 |
+
subject: str = "fastmcp-user",
|
| 94 |
+
issuer: str = "https://fastmcp.example.com",
|
| 95 |
+
audience: str | None = None,
|
| 96 |
+
scopes: list[str] | None = None,
|
| 97 |
+
expires_in_seconds: int = 3600,
|
| 98 |
+
additional_claims: dict[str, Any] | None = None,
|
| 99 |
+
) -> str:
|
| 100 |
+
"""
|
| 101 |
+
Generate a test JWT token for testing purposes.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
private_key_pem: RSA private key in PEM format
|
| 105 |
+
subject: Subject claim (usually user ID)
|
| 106 |
+
issuer: Issuer claim
|
| 107 |
+
audience: Audience claim (optional)
|
| 108 |
+
scopes: List of scopes to include
|
| 109 |
+
expires_in_seconds: Token expiration time in seconds
|
| 110 |
+
additional_claims: Any additional claims to include
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Signed JWT token string
|
| 114 |
+
"""
|
| 115 |
+
jwt = JsonWebToken(["RS256"])
|
| 116 |
+
|
| 117 |
+
now = int(time.time())
|
| 118 |
+
|
| 119 |
+
# Build payload
|
| 120 |
+
payload = {
|
| 121 |
+
"iss": issuer,
|
| 122 |
+
"sub": subject,
|
| 123 |
+
"iat": now,
|
| 124 |
+
"exp": now + expires_in_seconds,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if audience:
|
| 128 |
+
payload["aud"] = audience
|
| 129 |
+
|
| 130 |
+
if scopes:
|
| 131 |
+
payload["scope"] = " ".join(scopes)
|
| 132 |
+
|
| 133 |
+
if additional_claims:
|
| 134 |
+
payload.update(additional_claims)
|
| 135 |
+
|
| 136 |
+
# Create header
|
| 137 |
+
header = {"alg": "RS256"}
|
| 138 |
+
|
| 139 |
+
# Sign and return token
|
| 140 |
+
token_bytes = jwt.encode(
|
| 141 |
+
header,
|
| 142 |
+
payload,
|
| 143 |
+
key=self.private_key.get_secret_value(),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return token_bytes.decode("utf-8")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class BearerAuthProvider(OAuthProvider):
|
| 150 |
+
"""
|
| 151 |
+
Simple JWT Bearer Token validator for hosted MCP servers.
|
| 152 |
+
Uses RS256 asymmetric encryption. Supports either static public key
|
| 153 |
+
or JWKS URI for key rotation.
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
def __init__(
|
| 157 |
+
self,
|
| 158 |
+
issuer: str | None = None,
|
| 159 |
+
public_key: str | None = None,
|
| 160 |
+
jwks_uri: str | None = None,
|
| 161 |
+
audience: str | None = None,
|
| 162 |
+
required_scopes: list[str] | None = None,
|
| 163 |
+
):
|
| 164 |
+
"""
|
| 165 |
+
Initialize the provider.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
issuer: Expected issuer claim (your control plane)
|
| 169 |
+
public_key: RSA public key in PEM format (for static key)
|
| 170 |
+
jwks_uri: URI to fetch keys from (for key rotation)
|
| 171 |
+
audience: Expected audience claim (optional)
|
| 172 |
+
required_scopes: List of required scopes for access
|
| 173 |
+
"""
|
| 174 |
+
if not (public_key or jwks_uri):
|
| 175 |
+
raise ValueError("Either public_key or jwks_uri must be provided")
|
| 176 |
+
if public_key and jwks_uri:
|
| 177 |
+
raise ValueError("Provide either public_key or jwks_uri, not both")
|
| 178 |
+
|
| 179 |
+
super().__init__(
|
| 180 |
+
issuer_url=issuer or "http://fastmcp.example.com",
|
| 181 |
+
client_registration_options=ClientRegistrationOptions(enabled=False),
|
| 182 |
+
revocation_options=RevocationOptions(enabled=False),
|
| 183 |
+
required_scopes=required_scopes,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
self.issuer = issuer
|
| 187 |
+
self.audience = audience
|
| 188 |
+
self.public_key = public_key
|
| 189 |
+
self.jwks_uri = jwks_uri
|
| 190 |
+
self.jwt = JsonWebToken(["RS256"])
|
| 191 |
+
|
| 192 |
+
# Simple JWKS cache
|
| 193 |
+
self._jwks_cache: dict[str, str] = {}
|
| 194 |
+
self._jwks_cache_time: float = 0
|
| 195 |
+
self._cache_ttl = 3600 # 1 hour
|
| 196 |
+
|
| 197 |
+
async def _get_verification_key(self, token: str) -> str:
|
| 198 |
+
"""Get the verification key for the token."""
|
| 199 |
+
if self.public_key:
|
| 200 |
+
return self.public_key
|
| 201 |
+
|
| 202 |
+
# Extract kid from token header for JWKS lookup
|
| 203 |
+
try:
|
| 204 |
+
import base64
|
| 205 |
+
import json
|
| 206 |
+
|
| 207 |
+
header_b64 = token.split(".")[0]
|
| 208 |
+
header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
|
| 209 |
+
header = json.loads(base64.urlsafe_b64decode(header_b64))
|
| 210 |
+
kid = header.get("kid")
|
| 211 |
+
|
| 212 |
+
if not kid:
|
| 213 |
+
raise ValueError("Token missing key ID (kid)")
|
| 214 |
+
|
| 215 |
+
return await self._get_jwks_key(kid)
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
raise ValueError(f"Failed to extract key ID from token: {e}")
|
| 219 |
+
|
| 220 |
+
async def _get_jwks_key(self, kid: str) -> str:
|
| 221 |
+
"""Fetch key from JWKS with simple caching."""
|
| 222 |
+
if not self.jwks_uri:
|
| 223 |
+
raise ValueError("JWKS URI not configured")
|
| 224 |
+
|
| 225 |
+
current_time = time.time()
|
| 226 |
+
|
| 227 |
+
# Check cache
|
| 228 |
+
if (
|
| 229 |
+
current_time - self._jwks_cache_time < self._cache_ttl
|
| 230 |
+
and kid in self._jwks_cache
|
| 231 |
+
):
|
| 232 |
+
return self._jwks_cache[kid]
|
| 233 |
+
|
| 234 |
+
# Fetch JWKS
|
| 235 |
+
try:
|
| 236 |
+
async with httpx.AsyncClient() as client:
|
| 237 |
+
response = await client.get(self.jwks_uri)
|
| 238 |
+
response.raise_for_status()
|
| 239 |
+
jwks_data = response.json()
|
| 240 |
+
|
| 241 |
+
# Cache all keys
|
| 242 |
+
self._jwks_cache = {}
|
| 243 |
+
for key_data in jwks_data.get("keys", []):
|
| 244 |
+
key_kid = key_data.get("kid")
|
| 245 |
+
if key_kid:
|
| 246 |
+
jwk = JsonWebKey.import_key(key_data)
|
| 247 |
+
self._jwks_cache[key_kid] = jwk.get_public_key()
|
| 248 |
+
|
| 249 |
+
self._jwks_cache_time = current_time
|
| 250 |
+
|
| 251 |
+
if kid not in self._jwks_cache:
|
| 252 |
+
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 253 |
+
|
| 254 |
+
return self._jwks_cache[kid]
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 258 |
+
|
| 259 |
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 260 |
+
"""
|
| 261 |
+
Validates the provided JWT bearer token.
|
| 262 |
+
|
| 263 |
+
Args:
|
| 264 |
+
token: The JWT token string to validate
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
AccessToken object if valid, None if invalid or expired
|
| 268 |
+
"""
|
| 269 |
+
try:
|
| 270 |
+
# Get verification key (static or from JWKS)
|
| 271 |
+
verification_key = await self._get_verification_key(token)
|
| 272 |
+
|
| 273 |
+
# Decode and verify the JWT token
|
| 274 |
+
claims = self.jwt.decode(token, verification_key)
|
| 275 |
+
|
| 276 |
+
# Validate expiration
|
| 277 |
+
exp = claims.get("exp")
|
| 278 |
+
if exp and exp < time.time():
|
| 279 |
+
return None
|
| 280 |
+
|
| 281 |
+
# Validate issuer
|
| 282 |
+
if self.issuer:
|
| 283 |
+
if claims.get("iss") != self.issuer:
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
# Validate audience if configured
|
| 287 |
+
if self.audience:
|
| 288 |
+
aud = claims.get("aud")
|
| 289 |
+
if isinstance(aud, list):
|
| 290 |
+
if self.audience not in aud:
|
| 291 |
+
return None
|
| 292 |
+
elif aud != self.audience:
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
# Extract claims - prefer client_id over sub for OAuth application identification
|
| 296 |
+
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
| 297 |
+
scopes = self._extract_scopes(claims)
|
| 298 |
+
|
| 299 |
+
return AccessToken(
|
| 300 |
+
token=token,
|
| 301 |
+
client_id=str(client_id),
|
| 302 |
+
scopes=scopes,
|
| 303 |
+
expires_at=int(exp) if exp else None,
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
except JoseError:
|
| 307 |
+
return None
|
| 308 |
+
except Exception:
|
| 309 |
+
return None
|
| 310 |
+
|
| 311 |
+
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
| 312 |
+
"""Extract scopes from JWT claims."""
|
| 313 |
+
scope_claim = claims.get("scope", "")
|
| 314 |
+
if isinstance(scope_claim, str):
|
| 315 |
+
return scope_claim.split()
|
| 316 |
+
elif isinstance(scope_claim, list):
|
| 317 |
+
return scope_claim
|
| 318 |
+
return []
|
| 319 |
+
|
| 320 |
+
# --- Unused OAuth server methods ---
|
| 321 |
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 322 |
+
raise NotImplementedError("Client management not supported")
|
| 323 |
+
|
| 324 |
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 325 |
+
raise NotImplementedError("Client registration not supported")
|
| 326 |
+
|
| 327 |
+
async def authorize(
|
| 328 |
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
| 329 |
+
) -> str:
|
| 330 |
+
raise NotImplementedError("Authorization flow not supported")
|
| 331 |
+
|
| 332 |
+
async def load_authorization_code(
|
| 333 |
+
self, client: OAuthClientInformationFull, authorization_code: str
|
| 334 |
+
) -> AuthorizationCode | None:
|
| 335 |
+
raise NotImplementedError("Authorization code flow not supported")
|
| 336 |
+
|
| 337 |
+
async def exchange_authorization_code(
|
| 338 |
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
| 339 |
+
) -> OAuthToken:
|
| 340 |
+
raise NotImplementedError("Authorization code exchange not supported")
|
| 341 |
+
|
| 342 |
+
async def load_refresh_token(
|
| 343 |
+
self, client: OAuthClientInformationFull, refresh_token: str
|
| 344 |
+
) -> RefreshToken | None:
|
| 345 |
+
raise NotImplementedError("Refresh token flow not supported")
|
| 346 |
+
|
| 347 |
+
async def exchange_refresh_token(
|
| 348 |
+
self,
|
| 349 |
+
client: OAuthClientInformationFull,
|
| 350 |
+
refresh_token: RefreshToken,
|
| 351 |
+
scopes: list[str],
|
| 352 |
+
) -> OAuthToken:
|
| 353 |
+
raise NotImplementedError("Refresh token exchange not supported")
|
| 354 |
+
|
| 355 |
+
async def revoke_token(
|
| 356 |
+
self,
|
| 357 |
+
token: AccessToken | RefreshToken,
|
| 358 |
+
) -> None:
|
| 359 |
+
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 "
|
| 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,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Generator
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from fastmcp import Client, FastMCP
|
| 7 |
+
from fastmcp.client.auth import BearerAuth
|
| 8 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 9 |
+
from fastmcp.utilities.tests import run_server_in_process
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture(scope="module")
|
| 13 |
+
def rsa_key_pair() -> RSAKeyPair:
|
| 14 |
+
return RSAKeyPair.generate()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture(scope="module")
|
| 18 |
+
def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
|
| 19 |
+
return rsa_key_pair.create_token(
|
| 20 |
+
subject="test-user",
|
| 21 |
+
issuer="https://test.example.com",
|
| 22 |
+
audience="https://api.example.com",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
|
| 28 |
+
return BearerAuthProvider(
|
| 29 |
+
public_key=rsa_key_pair.public_key,
|
| 30 |
+
issuer="https://test.example.com",
|
| 31 |
+
audience="https://api.example.com",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> str:
|
| 36 |
+
mcp = FastMCP(
|
| 37 |
+
auth=BearerAuthProvider(
|
| 38 |
+
issuer="https://test.example.com",
|
| 39 |
+
public_key=public_key,
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
@mcp.tool()
|
| 44 |
+
def add(a: int, b: int) -> int:
|
| 45 |
+
return a + b
|
| 46 |
+
|
| 47 |
+
mcp.run(host=host, port=port, **kwargs)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture(scope="module")
|
| 51 |
+
def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
|
| 52 |
+
with run_server_in_process(
|
| 53 |
+
run_mcp_server, public_key=rsa_key_pair.public_key, transport="streamable-http"
|
| 54 |
+
) as url:
|
| 55 |
+
yield f"{url}/mcp"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestRSAKeyPair:
|
| 59 |
+
def test_generate_key_pair(self):
|
| 60 |
+
"""Test RSA key pair generation."""
|
| 61 |
+
key_pair = RSAKeyPair.generate()
|
| 62 |
+
|
| 63 |
+
assert key_pair.private_key is not None
|
| 64 |
+
assert key_pair.public_key is not None
|
| 65 |
+
|
| 66 |
+
# Check that keys are in PEM format
|
| 67 |
+
private_pem = key_pair.private_key.get_secret_value()
|
| 68 |
+
public_pem = key_pair.public_key.get_secret_value()
|
| 69 |
+
|
| 70 |
+
assert "-----BEGIN PRIVATE KEY-----" in private_pem
|
| 71 |
+
assert "-----END PRIVATE KEY-----" in private_pem
|
| 72 |
+
assert "-----BEGIN PUBLIC KEY-----" in public_pem
|
| 73 |
+
assert "-----END PUBLIC KEY-----" in public_pem
|
| 74 |
+
|
| 75 |
+
def test_create_basic_token(self, rsa_key_pair: RSAKeyPair):
|
| 76 |
+
"""Test basic token creation."""
|
| 77 |
+
token = rsa_key_pair.create_token(
|
| 78 |
+
subject="test-user", issuer="https://test.example.com"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
assert isinstance(token, str)
|
| 82 |
+
assert len(token.split(".")) == 3 # JWT has 3 parts
|
| 83 |
+
|
| 84 |
+
def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair):
|
| 85 |
+
"""Test token creation with scopes."""
|
| 86 |
+
token = rsa_key_pair.create_token(
|
| 87 |
+
subject="test-user",
|
| 88 |
+
issuer="https://test.example.com",
|
| 89 |
+
scopes=["read", "write"],
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
assert isinstance(token, str)
|
| 93 |
+
# We'll validate the scopes in the BearerToken tests
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class TestBearerToken:
|
| 97 |
+
def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
|
| 98 |
+
"""Test provider initialization with public key."""
|
| 99 |
+
provider = BearerAuthProvider(
|
| 100 |
+
public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
assert provider.issuer == "https://test.example.com"
|
| 104 |
+
assert provider.public_key is not None
|
| 105 |
+
assert provider.jwks_uri is None
|
| 106 |
+
|
| 107 |
+
def test_initialization_with_jwks_uri(self):
|
| 108 |
+
"""Test provider initialization with JWKS URI."""
|
| 109 |
+
provider = BearerAuthProvider(
|
| 110 |
+
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 111 |
+
issuer="https://test.example.com",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
assert provider.issuer == "https://test.example.com"
|
| 115 |
+
assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
|
| 116 |
+
assert provider.public_key is None
|
| 117 |
+
|
| 118 |
+
def test_initialization_requires_key_or_uri(self):
|
| 119 |
+
"""Test that either public_key or jwks_uri is required."""
|
| 120 |
+
with pytest.raises(
|
| 121 |
+
ValueError, match="Either public_key or jwks_uri must be provided"
|
| 122 |
+
):
|
| 123 |
+
BearerAuthProvider(issuer="https://test.example.com")
|
| 124 |
+
|
| 125 |
+
def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
|
| 126 |
+
"""Test that both public_key and jwks_uri cannot be provided."""
|
| 127 |
+
with pytest.raises(
|
| 128 |
+
ValueError, match="Provide either public_key or jwks_uri, not both"
|
| 129 |
+
):
|
| 130 |
+
BearerAuthProvider(
|
| 131 |
+
public_key=rsa_key_pair.public_key,
|
| 132 |
+
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 133 |
+
issuer="https://test.example.com",
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
@pytest.mark.asyncio
|
| 137 |
+
async def test_valid_token_validation(
|
| 138 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 139 |
+
):
|
| 140 |
+
"""Test validation of a valid token."""
|
| 141 |
+
token = rsa_key_pair.create_token(
|
| 142 |
+
subject="test-user",
|
| 143 |
+
issuer="https://test.example.com",
|
| 144 |
+
audience="https://api.example.com",
|
| 145 |
+
scopes=["read", "write"],
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 149 |
+
|
| 150 |
+
assert access_token is not None
|
| 151 |
+
assert access_token.client_id == "test-user"
|
| 152 |
+
assert "read" in access_token.scopes
|
| 153 |
+
assert "write" in access_token.scopes
|
| 154 |
+
assert access_token.expires_at is not None
|
| 155 |
+
|
| 156 |
+
@pytest.mark.asyncio
|
| 157 |
+
async def test_expired_token_rejection(
|
| 158 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 159 |
+
):
|
| 160 |
+
"""Test rejection of expired tokens."""
|
| 161 |
+
token = rsa_key_pair.create_token(
|
| 162 |
+
subject="test-user",
|
| 163 |
+
issuer="https://test.example.com",
|
| 164 |
+
audience="https://api.example.com",
|
| 165 |
+
expires_in_seconds=-3600, # Expired 1 hour ago
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 169 |
+
assert access_token is None
|
| 170 |
+
|
| 171 |
+
@pytest.mark.asyncio
|
| 172 |
+
async def test_invalid_issuer_rejection(
|
| 173 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 174 |
+
):
|
| 175 |
+
"""Test rejection of tokens with invalid issuer."""
|
| 176 |
+
token = rsa_key_pair.create_token(
|
| 177 |
+
subject="test-user",
|
| 178 |
+
issuer="https://evil.example.com", # Wrong issuer
|
| 179 |
+
audience="https://api.example.com",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 183 |
+
assert access_token is None
|
| 184 |
+
|
| 185 |
+
@pytest.mark.asyncio
|
| 186 |
+
async def test_invalid_audience_rejection(
|
| 187 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 188 |
+
):
|
| 189 |
+
"""Test rejection of tokens with invalid audience."""
|
| 190 |
+
token = rsa_key_pair.create_token(
|
| 191 |
+
subject="test-user",
|
| 192 |
+
issuer="https://test.example.com",
|
| 193 |
+
audience="https://wrong-api.example.com", # Wrong audience
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 197 |
+
assert access_token is None
|
| 198 |
+
|
| 199 |
+
@pytest.mark.asyncio
|
| 200 |
+
async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 201 |
+
"""Test that issuer validation is skipped when provider has no issuer configured."""
|
| 202 |
+
provider = BearerAuthProvider(
|
| 203 |
+
public_key=rsa_key_pair.public_key,
|
| 204 |
+
issuer=None, # No issuer validation
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
token = rsa_key_pair.create_token(
|
| 208 |
+
subject="test-user", issuer="https://any.example.com"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
access_token = await provider.load_access_token(token)
|
| 212 |
+
assert access_token is not None
|
| 213 |
+
|
| 214 |
+
@pytest.mark.asyncio
|
| 215 |
+
async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 216 |
+
"""Test that audience validation is skipped when provider has no audience configured."""
|
| 217 |
+
provider = BearerAuthProvider(
|
| 218 |
+
public_key=rsa_key_pair.public_key,
|
| 219 |
+
issuer="https://test.example.com",
|
| 220 |
+
audience=None, # No audience validation
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
token = rsa_key_pair.create_token(
|
| 224 |
+
subject="test-user",
|
| 225 |
+
issuer="https://test.example.com",
|
| 226 |
+
audience="https://any-api.example.com",
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
access_token = await provider.load_access_token(token)
|
| 230 |
+
assert access_token is not None
|
| 231 |
+
|
| 232 |
+
@pytest.mark.asyncio
|
| 233 |
+
async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
|
| 234 |
+
"""Test validation with multiple audiences in token."""
|
| 235 |
+
provider = BearerAuthProvider(
|
| 236 |
+
public_key=rsa_key_pair.public_key,
|
| 237 |
+
issuer="https://test.example.com",
|
| 238 |
+
audience="https://api.example.com",
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
token = rsa_key_pair.create_token(
|
| 242 |
+
subject="test-user",
|
| 243 |
+
issuer="https://test.example.com",
|
| 244 |
+
additional_claims={
|
| 245 |
+
"aud": ["https://api.example.com", "https://other-api.example.com"]
|
| 246 |
+
},
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
access_token = await provider.load_access_token(token)
|
| 250 |
+
assert access_token is not None
|
| 251 |
+
|
| 252 |
+
@pytest.mark.asyncio
|
| 253 |
+
async def test_scope_extraction_string(
|
| 254 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 255 |
+
):
|
| 256 |
+
"""Test scope extraction from space-separated string."""
|
| 257 |
+
token = rsa_key_pair.create_token(
|
| 258 |
+
subject="test-user",
|
| 259 |
+
issuer="https://test.example.com",
|
| 260 |
+
audience="https://api.example.com",
|
| 261 |
+
scopes=["read", "write", "admin"],
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 265 |
+
|
| 266 |
+
assert access_token is not None
|
| 267 |
+
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 268 |
+
|
| 269 |
+
@pytest.mark.asyncio
|
| 270 |
+
async def test_scope_extraction_list(
|
| 271 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 272 |
+
):
|
| 273 |
+
"""Test scope extraction from list format."""
|
| 274 |
+
token = rsa_key_pair.create_token(
|
| 275 |
+
subject="test-user",
|
| 276 |
+
issuer="https://test.example.com",
|
| 277 |
+
audience="https://api.example.com",
|
| 278 |
+
additional_claims={"scope": ["read", "write"]}, # List format
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 282 |
+
|
| 283 |
+
assert access_token is not None
|
| 284 |
+
assert set(access_token.scopes) == {"read", "write"}
|
| 285 |
+
|
| 286 |
+
@pytest.mark.asyncio
|
| 287 |
+
async def test_no_scopes(
|
| 288 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 289 |
+
):
|
| 290 |
+
"""Test token with no scopes."""
|
| 291 |
+
token = rsa_key_pair.create_token(
|
| 292 |
+
subject="test-user",
|
| 293 |
+
issuer="https://test.example.com",
|
| 294 |
+
audience="https://api.example.com",
|
| 295 |
+
# No scopes
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 299 |
+
|
| 300 |
+
assert access_token is not None
|
| 301 |
+
assert access_token.scopes == []
|
| 302 |
+
|
| 303 |
+
@pytest.mark.asyncio
|
| 304 |
+
async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
|
| 305 |
+
"""Test rejection of malformed tokens."""
|
| 306 |
+
malformed_tokens = [
|
| 307 |
+
"not.a.jwt",
|
| 308 |
+
"too.many.parts.here.invalid",
|
| 309 |
+
"invalid-token",
|
| 310 |
+
"",
|
| 311 |
+
"header.body", # Missing signature
|
| 312 |
+
]
|
| 313 |
+
|
| 314 |
+
for token in malformed_tokens:
|
| 315 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 316 |
+
assert access_token is None
|
| 317 |
+
|
| 318 |
+
@pytest.mark.asyncio
|
| 319 |
+
async def test_invalid_signature_rejection(
|
| 320 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 321 |
+
):
|
| 322 |
+
"""Test rejection of tokens with invalid signatures."""
|
| 323 |
+
# Create a token with a different key pair
|
| 324 |
+
other_key_pair = RSAKeyPair.generate()
|
| 325 |
+
token = other_key_pair.create_token(
|
| 326 |
+
subject="test-user",
|
| 327 |
+
issuer="https://test.example.com",
|
| 328 |
+
audience="https://api.example.com",
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 332 |
+
assert access_token is None
|
| 333 |
+
|
| 334 |
+
@pytest.mark.asyncio
|
| 335 |
+
async def test_client_id_fallback(
|
| 336 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 337 |
+
):
|
| 338 |
+
"""Test client_id extraction with fallback logic."""
|
| 339 |
+
# Test with explicit client_id claim
|
| 340 |
+
token = rsa_key_pair.create_token(
|
| 341 |
+
subject="user123",
|
| 342 |
+
issuer="https://test.example.com",
|
| 343 |
+
audience="https://api.example.com",
|
| 344 |
+
additional_claims={"client_id": "app456"},
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 348 |
+
assert access_token is not None
|
| 349 |
+
assert access_token.client_id == "app456" # Should prefer client_id over sub
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class TestFastMCPBearerAuth:
|
| 353 |
+
def test_bearer_auth(self):
|
| 354 |
+
mcp = FastMCP(
|
| 355 |
+
auth=BearerAuthProvider(issuer="https://test.example.com", public_key="abc")
|
| 356 |
+
)
|
| 357 |
+
assert isinstance(mcp.auth, BearerAuthProvider)
|
| 358 |
+
|
| 359 |
+
async def test_unauthorized_access(self, mcp_server_url: str):
|
| 360 |
+
with pytest.raises(httpx.HTTPStatusError, match="401"):
|
| 361 |
+
async with Client(mcp_server_url) as client:
|
| 362 |
+
await client.ping()
|
| 363 |
+
|
| 364 |
+
async def test_authorized_access(self, mcp_server_url: str, bearer_token):
|
| 365 |
+
async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
|
| 366 |
+
await client.ping()
|
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.
|
| 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=
|
| 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,6 +449,7 @@ 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" },
|
|
@@ -482,6 +483,7 @@ 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" },
|
|
@@ -998,6 +1000,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"
|
|
|
|
| 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" },
|
|
|
|
| 483 |
{ name = "ipython", specifier = ">=8.12.3" },
|
| 484 |
{ name = "pdbpp", specifier = ">=0.10.3" },
|
| 485 |
{ name = "pre-commit" },
|
| 486 |
+
{ name = "pyinstrument", specifier = ">=5.0.2" },
|
| 487 |
{ name = "pyright", specifier = ">=1.1.389" },
|
| 488 |
{ name = "pytest", specifier = ">=8.3.3" },
|
| 489 |
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
|
|
|
| 1000 |
{ 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" },
|
| 1001 |
]
|
| 1002 |
|
| 1003 |
+
[[package]]
|
| 1004 |
+
name = "pyinstrument"
|
| 1005 |
+
version = "5.0.2"
|
| 1006 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1007 |
+
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" }
|
| 1008 |
+
wheels = [
|
| 1009 |
+
{ 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" },
|
| 1010 |
+
{ 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" },
|
| 1011 |
+
{ 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" },
|
| 1012 |
+
{ 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" },
|
| 1013 |
+
{ 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" },
|
| 1014 |
+
{ 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" },
|
| 1015 |
+
{ 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" },
|
| 1016 |
+
{ 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" },
|
| 1017 |
+
{ 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" },
|
| 1018 |
+
{ 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" },
|
| 1019 |
+
{ 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" },
|
| 1020 |
+
{ 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" },
|
| 1021 |
+
{ 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" },
|
| 1022 |
+
{ 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" },
|
| 1023 |
+
{ 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" },
|
| 1024 |
+
{ 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" },
|
| 1025 |
+
{ 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" },
|
| 1026 |
+
{ 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" },
|
| 1027 |
+
{ 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" },
|
| 1028 |
+
{ 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" },
|
| 1029 |
+
{ 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" },
|
| 1030 |
+
{ 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" },
|
| 1031 |
+
{ 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" },
|
| 1032 |
+
{ 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" },
|
| 1033 |
+
{ 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" },
|
| 1034 |
+
{ 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" },
|
| 1035 |
+
{ 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" },
|
| 1036 |
+
{ 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" },
|
| 1037 |
+
{ 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" },
|
| 1038 |
+
{ 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" },
|
| 1039 |
+
{ 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" },
|
| 1040 |
+
{ 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" },
|
| 1041 |
+
{ 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" },
|
| 1042 |
+
{ 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" },
|
| 1043 |
+
{ 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" },
|
| 1044 |
+
{ 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" },
|
| 1045 |
+
{ 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" },
|
| 1046 |
+
{ 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" },
|
| 1047 |
+
{ 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" },
|
| 1048 |
+
{ 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" },
|
| 1049 |
+
{ 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" },
|
| 1050 |
+
{ 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" },
|
| 1051 |
+
{ 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" },
|
| 1052 |
+
{ 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" },
|
| 1053 |
+
{ 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" },
|
| 1054 |
+
{ 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" },
|
| 1055 |
+
{ 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" },
|
| 1056 |
+
{ 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" },
|
| 1057 |
+
]
|
| 1058 |
+
|
| 1059 |
[[package]]
|
| 1060 |
name = "pyperclip"
|
| 1061 |
version = "1.9.0"
|