fastmcp 2.10.6__py3-none-any.whl → 2.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. fastmcp/cli/cli.py +128 -33
  2. fastmcp/cli/install/claude_code.py +42 -1
  3. fastmcp/cli/install/claude_desktop.py +42 -1
  4. fastmcp/cli/install/cursor.py +42 -1
  5. fastmcp/cli/install/mcp_json.py +41 -0
  6. fastmcp/cli/run.py +127 -1
  7. fastmcp/client/__init__.py +2 -0
  8. fastmcp/client/auth/oauth.py +68 -99
  9. fastmcp/client/oauth_callback.py +18 -0
  10. fastmcp/client/transports.py +69 -15
  11. fastmcp/contrib/component_manager/example.py +2 -2
  12. fastmcp/experimental/server/openapi/README.md +266 -0
  13. fastmcp/experimental/server/openapi/__init__.py +38 -0
  14. fastmcp/experimental/server/openapi/components.py +348 -0
  15. fastmcp/experimental/server/openapi/routing.py +132 -0
  16. fastmcp/experimental/server/openapi/server.py +466 -0
  17. fastmcp/experimental/utilities/openapi/README.md +239 -0
  18. fastmcp/experimental/utilities/openapi/__init__.py +68 -0
  19. fastmcp/experimental/utilities/openapi/director.py +208 -0
  20. fastmcp/experimental/utilities/openapi/formatters.py +355 -0
  21. fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
  22. fastmcp/experimental/utilities/openapi/models.py +85 -0
  23. fastmcp/experimental/utilities/openapi/parser.py +618 -0
  24. fastmcp/experimental/utilities/openapi/schemas.py +538 -0
  25. fastmcp/mcp_config.py +125 -88
  26. fastmcp/prompts/prompt.py +11 -1
  27. fastmcp/resources/resource.py +21 -1
  28. fastmcp/resources/template.py +20 -1
  29. fastmcp/server/auth/__init__.py +18 -2
  30. fastmcp/server/auth/auth.py +225 -7
  31. fastmcp/server/auth/providers/bearer.py +25 -473
  32. fastmcp/server/auth/providers/in_memory.py +4 -2
  33. fastmcp/server/auth/providers/jwt.py +538 -0
  34. fastmcp/server/auth/providers/workos.py +151 -0
  35. fastmcp/server/auth/registry.py +52 -0
  36. fastmcp/server/context.py +107 -26
  37. fastmcp/server/dependencies.py +9 -2
  38. fastmcp/server/http.py +48 -57
  39. fastmcp/server/middleware/middleware.py +3 -23
  40. fastmcp/server/openapi.py +1 -1
  41. fastmcp/server/proxy.py +50 -11
  42. fastmcp/server/server.py +168 -59
  43. fastmcp/settings.py +73 -6
  44. fastmcp/tools/tool.py +36 -3
  45. fastmcp/tools/tool_manager.py +38 -2
  46. fastmcp/tools/tool_transform.py +112 -3
  47. fastmcp/utilities/components.py +41 -3
  48. fastmcp/utilities/json_schema.py +136 -98
  49. fastmcp/utilities/json_schema_type.py +1 -3
  50. fastmcp/utilities/mcp_config.py +28 -0
  51. fastmcp/utilities/openapi.py +243 -57
  52. fastmcp/utilities/tests.py +54 -6
  53. fastmcp/utilities/types.py +94 -11
  54. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/METADATA +4 -3
  55. fastmcp-2.11.1.dist-info/RECORD +108 -0
  56. fastmcp/server/auth/providers/bearer_env.py +0 -63
  57. fastmcp/utilities/cache.py +0 -26
  58. fastmcp-2.10.6.dist-info/RECORD +0 -93
  59. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/WHEEL +0 -0
  60. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/entry_points.txt +0 -0
  61. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,473 +1,25 @@
1
- import time
2
- from dataclasses import dataclass
3
- from typing import Any
4
-
5
- import httpx
6
- from authlib.jose import JsonWebKey, JsonWebToken
7
- from authlib.jose.errors import JoseError
8
- from cryptography.hazmat.primitives import serialization
9
- from cryptography.hazmat.primitives.asymmetric import rsa
10
- from mcp.server.auth.provider import (
11
- AccessToken,
12
- AuthorizationCode,
13
- AuthorizationParams,
14
- RefreshToken,
15
- )
16
- from mcp.shared.auth import (
17
- OAuthClientInformationFull,
18
- OAuthToken,
19
- )
20
- from pydantic import AnyHttpUrl, SecretStr, ValidationError
21
- from typing_extensions import TypedDict
22
-
23
- from fastmcp.server.auth.auth import (
24
- ClientRegistrationOptions,
25
- OAuthProvider,
26
- RevocationOptions,
27
- )
28
- from fastmcp.utilities.logging import get_logger
29
-
30
-
31
- class JWKData(TypedDict, total=False):
32
- """JSON Web Key data structure."""
33
-
34
- kty: str # Key type (e.g., "RSA") - required
35
- kid: str # Key ID (optional but recommended)
36
- use: str # Usage (e.g., "sig")
37
- alg: str # Algorithm (e.g., "RS256")
38
- n: str # Modulus (for RSA keys)
39
- e: str # Exponent (for RSA keys)
40
- x5c: list[str] # X.509 certificate chain (for JWKs)
41
- x5t: str # X.509 certificate thumbprint (for JWKs)
42
-
43
-
44
- class JWKSData(TypedDict):
45
- """JSON Web Key Set data structure."""
46
-
47
- keys: list[JWKData]
48
-
49
-
50
- @dataclass(frozen=True, kw_only=True, repr=False)
51
- class RSAKeyPair:
52
- private_key: SecretStr
53
- public_key: str
54
-
55
- @classmethod
56
- def generate(cls) -> "RSAKeyPair":
57
- """
58
- Generate an RSA key pair for testing.
59
-
60
- Returns:
61
- tuple: (private_key_pem, public_key_pem)
62
- """
63
- # Generate private key
64
- private_key = rsa.generate_private_key(
65
- public_exponent=65537,
66
- key_size=2048,
67
- )
68
-
69
- # Get public key
70
- public_key = private_key.public_key()
71
-
72
- # Serialize private key to PEM format
73
- private_pem = private_key.private_bytes(
74
- encoding=serialization.Encoding.PEM,
75
- format=serialization.PrivateFormat.PKCS8,
76
- encryption_algorithm=serialization.NoEncryption(),
77
- ).decode("utf-8")
78
-
79
- # Serialize public key to PEM format
80
- public_pem = public_key.public_bytes(
81
- encoding=serialization.Encoding.PEM,
82
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
83
- ).decode("utf-8")
84
-
85
- return cls(
86
- private_key=SecretStr(private_pem),
87
- public_key=public_pem,
88
- )
89
-
90
- def create_token(
91
- self,
92
- subject: str = "fastmcp-user",
93
- issuer: str = "https://fastmcp.example.com",
94
- audience: str | list[str] | None = None,
95
- scopes: list[str] | None = None,
96
- expires_in_seconds: int = 3600,
97
- additional_claims: dict[str, Any] | None = None,
98
- kid: str | 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 - can be a string or list of strings (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
- kid: Key ID for JWKS lookup (optional)
112
-
113
- Returns:
114
- Signed JWT token string
115
- """
116
- # TODO : Add support for configurable algorithms
117
- jwt = JsonWebToken(["RS256"])
118
-
119
- now = int(time.time())
120
-
121
- # Build payload
122
- payload = {
123
- "iss": issuer,
124
- "sub": subject,
125
- "iat": now,
126
- "exp": now + expires_in_seconds,
127
- }
128
-
129
- if audience:
130
- payload["aud"] = audience
131
-
132
- if scopes:
133
- payload["scope"] = " ".join(scopes)
134
-
135
- if additional_claims:
136
- payload.update(additional_claims)
137
-
138
- # Create header
139
- header = {"alg": "RS256"}
140
- if kid:
141
- header["kid"] = kid
142
-
143
- # Sign and return token
144
- token_bytes = jwt.encode(
145
- header,
146
- payload,
147
- key=self.private_key.get_secret_value(),
148
- )
149
- return token_bytes.decode("utf-8")
150
-
151
-
152
- class BearerAuthProvider(OAuthProvider):
153
- """
154
- Simple JWT Bearer Token validator for hosted MCP servers.
155
- Uses RS256 asymmetric encryption by default but supports all JWA algorithms. Supports either static public key
156
- or JWKS URI for key rotation.
157
-
158
- Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows.
159
- It is intended to be used with a control plane that manages clients and tokens.
160
- """
161
-
162
- def __init__(
163
- self,
164
- public_key: str | None = None,
165
- jwks_uri: str | None = None,
166
- issuer: str | None = None,
167
- algorithm: str | None = None,
168
- audience: str | list[str] | None = None,
169
- required_scopes: list[str] | None = None,
170
- ):
171
- """
172
- Initialize the provider. Either public_key or jwks_uri must be provided.
173
-
174
- Args:
175
- public_key: RSA public key in PEM format (for static key)
176
- jwks_uri: URI to fetch keys from (for key rotation)
177
- issuer: Expected issuer claim (optional)
178
- algorithm: Algorithm to use for verification (optional, defaults to RS256)
179
- audience: Expected audience claim - can be a string or list of strings (optional)
180
- required_scopes: List of required scopes for access (optional)
181
- """
182
- if not (public_key or jwks_uri):
183
- raise ValueError("Either public_key or jwks_uri must be provided")
184
- if public_key and jwks_uri:
185
- raise ValueError("Provide either public_key or jwks_uri, not both")
186
-
187
- if not algorithm:
188
- algorithm = "RS256"
189
- if algorithm not in {
190
- "HS256",
191
- "HS384",
192
- "HS512",
193
- "RS256",
194
- "RS384",
195
- "RS512",
196
- "ES256",
197
- "ES384",
198
- "ES512",
199
- "PS256",
200
- "PS384",
201
- "PS512",
202
- }:
203
- raise ValueError(f"Unsupported algorithm: {algorithm}.")
204
-
205
- # Only pass issuer to parent if it's a valid URL, otherwise use default
206
- # This allows the issuer claim validation to work with string issuers per RFC 7519
207
- try:
208
- issuer_url = AnyHttpUrl(issuer) if issuer else "https://fastmcp.example.com"
209
- except ValidationError:
210
- # Issuer is not a valid URL, use default for parent class
211
- issuer_url = "https://fastmcp.example.com"
212
-
213
- super().__init__(
214
- issuer_url=issuer_url,
215
- client_registration_options=ClientRegistrationOptions(enabled=False),
216
- revocation_options=RevocationOptions(enabled=False),
217
- required_scopes=required_scopes,
218
- )
219
-
220
- self.algorithm = algorithm
221
- self.issuer = issuer
222
- self.audience = audience
223
- self.public_key = public_key
224
- self.jwks_uri = jwks_uri
225
- self.jwt = JsonWebToken([self.algorithm]) # Use RS256 by default
226
- self.logger = get_logger(__name__)
227
-
228
- # Simple JWKS cache
229
- self._jwks_cache: dict[str, str] = {}
230
- self._jwks_cache_time: float = 0
231
- self._cache_ttl = 3600 # 1 hour
232
-
233
- async def _get_verification_key(self, token: str) -> str:
234
- """Get the verification key for the token."""
235
- if self.public_key:
236
- return self.public_key
237
-
238
- # Extract kid from token header for JWKS lookup
239
- try:
240
- import base64
241
- import json
242
-
243
- header_b64 = token.split(".")[0]
244
- header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
245
- header = json.loads(base64.urlsafe_b64decode(header_b64))
246
- kid = header.get("kid")
247
-
248
- return await self._get_jwks_key(kid)
249
-
250
- except Exception as e:
251
- raise ValueError(f"Failed to extract key ID from token: {e}")
252
-
253
- async def _get_jwks_key(self, kid: str | None) -> str:
254
- """Fetch key from JWKS with simple caching."""
255
- if not self.jwks_uri:
256
- raise ValueError("JWKS URI not configured")
257
-
258
- current_time = time.time()
259
-
260
- # Check cache first
261
- if current_time - self._jwks_cache_time < self._cache_ttl:
262
- if kid and kid in self._jwks_cache:
263
- return self._jwks_cache[kid]
264
- elif not kid and len(self._jwks_cache) == 1:
265
- # If no kid but only one key cached, use it
266
- return next(iter(self._jwks_cache.values()))
267
-
268
- # Fetch JWKS
269
- try:
270
- async with httpx.AsyncClient() as client:
271
- response = await client.get(self.jwks_uri)
272
- response.raise_for_status()
273
- jwks_data = response.json()
274
-
275
- # Cache all keys
276
- self._jwks_cache = {}
277
- for key_data in jwks_data.get("keys", []):
278
- key_kid = key_data.get("kid")
279
- jwk = JsonWebKey.import_key(key_data)
280
- public_key = jwk.get_public_key() # type: ignore
281
-
282
- if key_kid:
283
- self._jwks_cache[key_kid] = public_key
284
- else:
285
- # Key without kid - use a default identifier
286
- self._jwks_cache["_default"] = public_key
287
-
288
- self._jwks_cache_time = current_time
289
-
290
- # Select the appropriate key
291
- if kid:
292
- if kid not in self._jwks_cache:
293
- self.logger.debug(
294
- "JWKS key lookup failed: key ID '%s' not found", kid
295
- )
296
- raise ValueError(f"Key ID '{kid}' not found in JWKS")
297
- return self._jwks_cache[kid]
298
- else:
299
- # No kid in token - only allow if there's exactly one key
300
- if len(self._jwks_cache) == 1:
301
- return next(iter(self._jwks_cache.values()))
302
- elif len(self._jwks_cache) > 1:
303
- raise ValueError(
304
- "Multiple keys in JWKS but no key ID (kid) in token"
305
- )
306
- else:
307
- raise ValueError("No keys found in JWKS")
308
-
309
- except Exception as e:
310
- self.logger.debug("JWKS fetch failed: %s", str(e))
311
- raise ValueError(f"Failed to fetch JWKS: {e}")
312
-
313
- async def load_access_token(self, token: str) -> AccessToken | None:
314
- """
315
- Validates the provided JWT bearer token.
316
-
317
- Args:
318
- token: The JWT token string to validate
319
-
320
- Returns:
321
- AccessToken object if valid, None if invalid or expired
322
- """
323
- try:
324
- # Get verification key (static or from JWKS)
325
- verification_key = await self._get_verification_key(token)
326
-
327
- # Decode and verify the JWT token
328
- claims = self.jwt.decode(token, verification_key)
329
-
330
- # Extract client ID early for logging
331
- client_id = claims.get("client_id") or claims.get("sub") or "unknown"
332
-
333
- # Validate expiration
334
- exp = claims.get("exp")
335
- if exp and exp < time.time():
336
- self.logger.debug(
337
- "Token validation failed: expired token for client %s", client_id
338
- )
339
- self.logger.info("Bearer token rejected for client %s", client_id)
340
- return None
341
-
342
- # Validate issuer - note we use issuer instead of issuer_url here because
343
- # issuer is optional, allowing users to make this check optional
344
- if self.issuer:
345
- if claims.get("iss") != self.issuer:
346
- self.logger.debug(
347
- "Token validation failed: issuer mismatch for client %s",
348
- client_id,
349
- )
350
- self.logger.info("Bearer token rejected for client %s", client_id)
351
- return None
352
-
353
- # Validate audience if configured
354
- if self.audience:
355
- aud = claims.get("aud")
356
-
357
- # Handle different combinations of audience types
358
- audience_valid = False
359
- if isinstance(self.audience, list):
360
- # self.audience is a list - check if any expected audience is present
361
- if isinstance(aud, list):
362
- # Both are lists - check for intersection
363
- audience_valid = any(
364
- expected in aud for expected in self.audience
365
- )
366
- else:
367
- # aud is a string - check if it's in our expected list
368
- audience_valid = aud in self.audience
369
- else:
370
- # self.audience is a string - use original logic
371
- if isinstance(aud, list):
372
- audience_valid = self.audience in aud
373
- else:
374
- audience_valid = aud == self.audience
375
-
376
- if not audience_valid:
377
- self.logger.debug(
378
- "Token validation failed: audience mismatch for client %s",
379
- client_id,
380
- )
381
- self.logger.info("Bearer token rejected for client %s", client_id)
382
- return None
383
-
384
- # Extract scopes
385
- scopes = self._extract_scopes(claims)
386
-
387
- return AccessToken(
388
- token=token,
389
- client_id=str(client_id),
390
- scopes=scopes,
391
- expires_at=int(exp) if exp else None,
392
- )
393
-
394
- except JoseError:
395
- self.logger.debug("Token validation failed: JWT signature/format invalid")
396
- return None
397
- except Exception as e:
398
- self.logger.debug("Token validation failed: %s", str(e))
399
- return None
400
-
401
- def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
402
- """
403
- Extract scopes from JWT claims. Supports both 'scope' and 'scp'
404
- claims.
405
-
406
- Checks the `scope` claim first (standard OAuth2 claim), then the `scp`
407
- claim (used by some Identity Providers).
408
- """
409
-
410
- for claim in ["scope", "scp"]:
411
- if claim in claims:
412
- if isinstance(claims[claim], str):
413
- return claims[claim].split()
414
- elif isinstance(claims[claim], list):
415
- return claims[claim]
416
-
417
- return []
418
-
419
- async def verify_token(self, token: str) -> AccessToken | None:
420
- """
421
- Verify a bearer token and return access info if valid.
422
-
423
- This method implements the TokenVerifier protocol by delegating
424
- to our existing load_access_token method.
425
-
426
- Args:
427
- token: The JWT token string to validate
428
-
429
- Returns:
430
- AccessToken object if valid, None if invalid or expired
431
- """
432
- return await self.load_access_token(token)
433
-
434
- # --- Unused OAuth server methods ---
435
- async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
436
- raise NotImplementedError("Client management not supported")
437
-
438
- async def register_client(self, client_info: OAuthClientInformationFull) -> None:
439
- raise NotImplementedError("Client registration not supported")
440
-
441
- async def authorize(
442
- self, client: OAuthClientInformationFull, params: AuthorizationParams
443
- ) -> str:
444
- raise NotImplementedError("Authorization flow not supported")
445
-
446
- async def load_authorization_code(
447
- self, client: OAuthClientInformationFull, authorization_code: str
448
- ) -> AuthorizationCode | None:
449
- raise NotImplementedError("Authorization code flow not supported")
450
-
451
- async def exchange_authorization_code(
452
- self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
453
- ) -> OAuthToken:
454
- raise NotImplementedError("Authorization code exchange not supported")
455
-
456
- async def load_refresh_token(
457
- self, client: OAuthClientInformationFull, refresh_token: str
458
- ) -> RefreshToken | None:
459
- raise NotImplementedError("Refresh token flow not supported")
460
-
461
- async def exchange_refresh_token(
462
- self,
463
- client: OAuthClientInformationFull,
464
- refresh_token: RefreshToken,
465
- scopes: list[str],
466
- ) -> OAuthToken:
467
- raise NotImplementedError("Refresh token exchange not supported")
468
-
469
- async def revoke_token(
470
- self,
471
- token: AccessToken | RefreshToken,
472
- ) -> None:
473
- raise NotImplementedError("Token revocation not supported")
1
+ """Backwards compatibility shim for BearerAuthProvider.
2
+
3
+ The BearerAuthProvider class has been moved to fastmcp.server.auth.providers.jwt.JWTVerifier
4
+ for better organization. This module provides a backwards-compatible import.
5
+ """
6
+
7
+ import warnings
8
+
9
+ import fastmcp
10
+ from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, RSAKeyPair
11
+ from fastmcp.server.auth.providers.jwt import JWTVerifier as BearerAuthProvider
12
+
13
+ # Re-export for backwards compatibility
14
+ __all__ = ["BearerAuthProvider", "RSAKeyPair", "JWKData", "JWKSData"]
15
+
16
+ # Deprecated in 2.11
17
+ if fastmcp.settings.deprecation_warnings:
18
+ warnings.warn(
19
+ "The `fastmcp.server.auth.providers.bearer` module is deprecated "
20
+ "and will be removed in a future version. "
21
+ "Please use `fastmcp.server.auth.providers.jwt.JWTVerifier` "
22
+ "instead of this module's BearerAuthProvider.",
23
+ DeprecationWarning,
24
+ stacklevel=2,
25
+ )
@@ -36,18 +36,20 @@ class InMemoryOAuthProvider(OAuthProvider):
36
36
 
37
37
  def __init__(
38
38
  self,
39
- issuer_url: AnyHttpUrl | str | None = None,
39
+ base_url: AnyHttpUrl | str | None = None,
40
40
  service_documentation_url: AnyHttpUrl | str | None = None,
41
41
  client_registration_options: ClientRegistrationOptions | None = None,
42
42
  revocation_options: RevocationOptions | None = None,
43
43
  required_scopes: list[str] | None = None,
44
+ resource_server_url: AnyHttpUrl | str | None = None,
44
45
  ):
45
46
  super().__init__(
46
- issuer_url=issuer_url or "http://fastmcp.example.com",
47
+ base_url=base_url or "http://fastmcp.example.com",
47
48
  service_documentation_url=service_documentation_url,
48
49
  client_registration_options=client_registration_options,
49
50
  revocation_options=revocation_options,
50
51
  required_scopes=required_scopes,
52
+ resource_server_url=resource_server_url,
51
53
  )
52
54
  self.clients: dict[str, OAuthClientInformationFull] = {}
53
55
  self.auth_codes: dict[str, AuthorizationCode] = {}