fastmcp 2.12.5__py3-none-any.whl → 2.14.0__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 (133) hide show
  1. fastmcp/__init__.py +2 -23
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +19 -33
  5. fastmcp/cli/install/claude_code.py +6 -6
  6. fastmcp/cli/install/claude_desktop.py +3 -3
  7. fastmcp/cli/install/cursor.py +18 -12
  8. fastmcp/cli/install/gemini_cli.py +3 -3
  9. fastmcp/cli/install/mcp_json.py +3 -3
  10. fastmcp/cli/install/shared.py +0 -15
  11. fastmcp/cli/run.py +13 -8
  12. fastmcp/cli/tasks.py +110 -0
  13. fastmcp/client/__init__.py +9 -9
  14. fastmcp/client/auth/oauth.py +123 -225
  15. fastmcp/client/client.py +697 -95
  16. fastmcp/client/elicitation.py +11 -5
  17. fastmcp/client/logging.py +18 -14
  18. fastmcp/client/messages.py +7 -5
  19. fastmcp/client/oauth_callback.py +85 -171
  20. fastmcp/client/roots.py +2 -1
  21. fastmcp/client/sampling.py +1 -1
  22. fastmcp/client/tasks.py +614 -0
  23. fastmcp/client/transports.py +117 -30
  24. fastmcp/contrib/component_manager/__init__.py +1 -1
  25. fastmcp/contrib/component_manager/component_manager.py +2 -2
  26. fastmcp/contrib/component_manager/component_service.py +10 -26
  27. fastmcp/contrib/mcp_mixin/README.md +32 -1
  28. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  29. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  30. fastmcp/dependencies.py +25 -0
  31. fastmcp/experimental/sampling/handlers/openai.py +3 -3
  32. fastmcp/experimental/server/openapi/__init__.py +20 -21
  33. fastmcp/experimental/utilities/openapi/__init__.py +16 -47
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +54 -51
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +43 -21
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +161 -61
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -14
  45. fastmcp/server/auth/auth.py +197 -46
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1469 -298
  50. fastmcp/server/auth/oidc_proxy.py +91 -20
  51. fastmcp/server/auth/providers/auth0.py +40 -21
  52. fastmcp/server/auth/providers/aws.py +29 -3
  53. fastmcp/server/auth/providers/azure.py +312 -131
  54. fastmcp/server/auth/providers/debug.py +114 -0
  55. fastmcp/server/auth/providers/descope.py +86 -29
  56. fastmcp/server/auth/providers/discord.py +308 -0
  57. fastmcp/server/auth/providers/github.py +29 -8
  58. fastmcp/server/auth/providers/google.py +48 -9
  59. fastmcp/server/auth/providers/in_memory.py +29 -5
  60. fastmcp/server/auth/providers/introspection.py +281 -0
  61. fastmcp/server/auth/providers/jwt.py +48 -31
  62. fastmcp/server/auth/providers/oci.py +233 -0
  63. fastmcp/server/auth/providers/scalekit.py +238 -0
  64. fastmcp/server/auth/providers/supabase.py +188 -0
  65. fastmcp/server/auth/providers/workos.py +35 -17
  66. fastmcp/server/context.py +236 -116
  67. fastmcp/server/dependencies.py +503 -18
  68. fastmcp/server/elicitation.py +286 -48
  69. fastmcp/server/event_store.py +177 -0
  70. fastmcp/server/http.py +71 -20
  71. fastmcp/server/low_level.py +165 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +50 -39
  76. fastmcp/server/middleware/middleware.py +29 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi/__init__.py +35 -0
  80. fastmcp/{experimental/server → server}/openapi/components.py +15 -10
  81. fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
  82. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  83. fastmcp/server/proxy.py +72 -48
  84. fastmcp/server/server.py +1415 -733
  85. fastmcp/server/tasks/__init__.py +21 -0
  86. fastmcp/server/tasks/capabilities.py +22 -0
  87. fastmcp/server/tasks/config.py +89 -0
  88. fastmcp/server/tasks/converters.py +205 -0
  89. fastmcp/server/tasks/handlers.py +356 -0
  90. fastmcp/server/tasks/keys.py +93 -0
  91. fastmcp/server/tasks/protocol.py +355 -0
  92. fastmcp/server/tasks/subscriptions.py +205 -0
  93. fastmcp/settings.py +125 -113
  94. fastmcp/tools/__init__.py +1 -1
  95. fastmcp/tools/tool.py +138 -55
  96. fastmcp/tools/tool_manager.py +30 -112
  97. fastmcp/tools/tool_transform.py +12 -21
  98. fastmcp/utilities/cli.py +67 -28
  99. fastmcp/utilities/components.py +10 -5
  100. fastmcp/utilities/inspect.py +79 -23
  101. fastmcp/utilities/json_schema.py +4 -4
  102. fastmcp/utilities/json_schema_type.py +8 -8
  103. fastmcp/utilities/logging.py +118 -8
  104. fastmcp/utilities/mcp_config.py +1 -2
  105. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  106. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  107. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  108. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
  109. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  110. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  111. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  112. fastmcp/utilities/openapi/__init__.py +63 -0
  113. fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
  114. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  115. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
  116. fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
  117. fastmcp/utilities/tests.py +92 -5
  118. fastmcp/utilities/types.py +86 -16
  119. fastmcp/utilities/ui.py +626 -0
  120. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
  121. fastmcp-2.14.0.dist-info/RECORD +156 -0
  122. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
  123. fastmcp/cli/claude.py +0 -135
  124. fastmcp/server/auth/providers/bearer.py +0 -25
  125. fastmcp/server/openapi.py +0 -1083
  126. fastmcp/utilities/openapi.py +0 -1568
  127. fastmcp/utilities/storage.py +0 -204
  128. fastmcp-2.12.5.dist-info/RECORD +0 -134
  129. fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  130. fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
  131. fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
  132. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
  133. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
@@ -22,15 +22,16 @@ Example:
22
22
  from __future__ import annotations
23
23
 
24
24
  import httpx
25
+ from key_value.aio.protocols import AsyncKeyValue
25
26
  from pydantic import AnyHttpUrl, SecretStr, field_validator
26
27
  from pydantic_settings import BaseSettings, SettingsConfigDict
27
28
 
28
29
  from fastmcp.server.auth import TokenVerifier
29
30
  from fastmcp.server.auth.auth import AccessToken
30
31
  from fastmcp.server.auth.oauth_proxy import OAuthProxy
32
+ from fastmcp.settings import ENV_FILE
31
33
  from fastmcp.utilities.auth import parse_scopes
32
34
  from fastmcp.utilities.logging import get_logger
33
- from fastmcp.utilities.storage import KVStorage
34
35
  from fastmcp.utilities.types import NotSet, NotSetT
35
36
 
36
37
  logger = get_logger(__name__)
@@ -41,17 +42,19 @@ class GitHubProviderSettings(BaseSettings):
41
42
 
42
43
  model_config = SettingsConfigDict(
43
44
  env_prefix="FASTMCP_SERVER_AUTH_GITHUB_",
44
- env_file=".env",
45
+ env_file=ENV_FILE,
45
46
  extra="ignore",
46
47
  )
47
48
 
48
49
  client_id: str | None = None
49
50
  client_secret: SecretStr | None = None
50
51
  base_url: AnyHttpUrl | str | None = None
52
+ issuer_url: AnyHttpUrl | str | None = None
51
53
  redirect_path: str | None = None
52
54
  required_scopes: list[str] | None = None
53
55
  timeout_seconds: int | None = None
54
56
  allowed_client_redirect_uris: list[str] | None = None
57
+ jwt_signing_key: str | None = None
55
58
 
56
59
  @field_validator("required_scopes", mode="before")
57
60
  @classmethod
@@ -198,25 +201,38 @@ class GitHubProvider(OAuthProxy):
198
201
  client_id: str | NotSetT = NotSet,
199
202
  client_secret: str | NotSetT = NotSet,
200
203
  base_url: AnyHttpUrl | str | NotSetT = NotSet,
204
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
201
205
  redirect_path: str | NotSetT = NotSet,
202
206
  required_scopes: list[str] | NotSetT = NotSet,
203
207
  timeout_seconds: int | NotSetT = NotSet,
204
208
  allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
205
- client_storage: KVStorage | None = None,
209
+ client_storage: AsyncKeyValue | None = None,
210
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
211
+ require_authorization_consent: bool = True,
206
212
  ):
207
213
  """Initialize GitHub OAuth provider.
208
214
 
209
215
  Args:
210
216
  client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
211
217
  client_secret: GitHub OAuth app client secret
212
- base_url: Public URL of your FastMCP server (for OAuth callbacks)
218
+ base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
219
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
220
+ to avoid 404s during discovery when mounting under a path.
213
221
  redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
214
222
  required_scopes: Required GitHub scopes (defaults to ["user"])
215
223
  timeout_seconds: HTTP request timeout for GitHub API calls
216
224
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
217
225
  If None (default), all URIs are allowed. If empty list, no URIs are allowed.
218
- client_storage: Storage implementation for OAuth client registrations.
219
- Defaults to file-based storage if not specified.
226
+ client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
227
+ If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
228
+ disk store will be encrypted using a key derived from the JWT Signing Key.
229
+ jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
230
+ they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
231
+ provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
232
+ require_authorization_consent: Whether to require user consent before authorizing clients (default True).
233
+ When True, users see a consent screen before being redirected to GitHub.
234
+ When False, authorization proceeds directly without user confirmation.
235
+ SECURITY WARNING: Only disable for local development or testing environments.
220
236
  """
221
237
 
222
238
  settings = GitHubProviderSettings.model_validate(
@@ -226,10 +242,12 @@ class GitHubProvider(OAuthProxy):
226
242
  "client_id": client_id,
227
243
  "client_secret": client_secret,
228
244
  "base_url": base_url,
245
+ "issuer_url": issuer_url,
229
246
  "redirect_path": redirect_path,
230
247
  "required_scopes": required_scopes,
231
248
  "timeout_seconds": timeout_seconds,
232
249
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
250
+ "jwt_signing_key": jwt_signing_key,
233
251
  }.items()
234
252
  if v is not NotSet
235
253
  }
@@ -271,12 +289,15 @@ class GitHubProvider(OAuthProxy):
271
289
  token_verifier=token_verifier,
272
290
  base_url=settings.base_url,
273
291
  redirect_path=settings.redirect_path,
274
- issuer_url=settings.base_url, # We act as the issuer for client registration
292
+ issuer_url=settings.issuer_url
293
+ or settings.base_url, # Default to base_url if not specified
275
294
  allowed_client_redirect_uris=allowed_client_redirect_uris_final,
276
295
  client_storage=client_storage,
296
+ jwt_signing_key=settings.jwt_signing_key,
297
+ require_authorization_consent=require_authorization_consent,
277
298
  )
278
299
 
279
- logger.info(
300
+ logger.debug(
280
301
  "Initialized GitHub OAuth provider for client %s with scopes: %s",
281
302
  settings.client_id,
282
303
  required_scopes_final,
@@ -24,15 +24,16 @@ from __future__ import annotations
24
24
  import time
25
25
 
26
26
  import httpx
27
+ from key_value.aio.protocols import AsyncKeyValue
27
28
  from pydantic import AnyHttpUrl, SecretStr, field_validator
28
29
  from pydantic_settings import BaseSettings, SettingsConfigDict
29
30
 
30
31
  from fastmcp.server.auth import TokenVerifier
31
32
  from fastmcp.server.auth.auth import AccessToken
32
33
  from fastmcp.server.auth.oauth_proxy import OAuthProxy
34
+ from fastmcp.settings import ENV_FILE
33
35
  from fastmcp.utilities.auth import parse_scopes
34
36
  from fastmcp.utilities.logging import get_logger
35
- from fastmcp.utilities.storage import KVStorage
36
37
  from fastmcp.utilities.types import NotSet, NotSetT
37
38
 
38
39
  logger = get_logger(__name__)
@@ -43,17 +44,19 @@ class GoogleProviderSettings(BaseSettings):
43
44
 
44
45
  model_config = SettingsConfigDict(
45
46
  env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_",
46
- env_file=".env",
47
+ env_file=ENV_FILE,
47
48
  extra="ignore",
48
49
  )
49
50
 
50
51
  client_id: str | None = None
51
52
  client_secret: SecretStr | None = None
52
53
  base_url: AnyHttpUrl | str | None = None
54
+ issuer_url: AnyHttpUrl | str | None = None
53
55
  redirect_path: str | None = None
54
56
  required_scopes: list[str] | None = None
55
57
  timeout_seconds: int | None = None
56
58
  allowed_client_redirect_uris: list[str] | None = None
59
+ jwt_signing_key: str | None = None
57
60
 
58
61
  @field_validator("required_scopes", mode="before")
59
62
  @classmethod
@@ -77,7 +80,7 @@ class GoogleTokenVerifier(TokenVerifier):
77
80
  """Initialize the Google token verifier.
78
81
 
79
82
  Args:
80
- required_scopes: Required OAuth scopes (e.g., ['openid', 'email'])
83
+ required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
81
84
  timeout_seconds: HTTP request timeout
82
85
  """
83
86
  super().__init__(required_scopes=required_scopes)
@@ -214,18 +217,24 @@ class GoogleProvider(OAuthProxy):
214
217
  client_id: str | NotSetT = NotSet,
215
218
  client_secret: str | NotSetT = NotSet,
216
219
  base_url: AnyHttpUrl | str | NotSetT = NotSet,
220
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
217
221
  redirect_path: str | NotSetT = NotSet,
218
222
  required_scopes: list[str] | NotSetT = NotSet,
219
223
  timeout_seconds: int | NotSetT = NotSet,
220
224
  allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
221
- client_storage: KVStorage | None = None,
225
+ client_storage: AsyncKeyValue | None = None,
226
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
227
+ require_authorization_consent: bool = True,
228
+ extra_authorize_params: dict[str, str] | None = None,
222
229
  ):
223
230
  """Initialize Google OAuth provider.
224
231
 
225
232
  Args:
226
233
  client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
227
234
  client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...")
228
- base_url: Public URL of your FastMCP server (for OAuth callbacks)
235
+ base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
236
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
237
+ to avoid 404s during discovery when mounting under a path.
229
238
  redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
230
239
  required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
231
240
  - "openid" for OpenID Connect (default)
@@ -234,8 +243,20 @@ class GoogleProvider(OAuthProxy):
234
243
  timeout_seconds: HTTP request timeout for Google API calls
235
244
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
236
245
  If None (default), all URIs are allowed. If empty list, no URIs are allowed.
237
- client_storage: Storage implementation for OAuth client registrations.
238
- Defaults to file-based storage if not specified.
246
+ client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
247
+ If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
248
+ disk store will be encrypted using a key derived from the JWT Signing Key.
249
+ jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
250
+ they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
251
+ provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
252
+ require_authorization_consent: Whether to require user consent before authorizing clients (default True).
253
+ When True, users see a consent screen before being redirected to Google.
254
+ When False, authorization proceeds directly without user confirmation.
255
+ SECURITY WARNING: Only disable for local development or testing environments.
256
+ extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
257
+ By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
258
+ refresh tokens are returned. You can override these defaults or add additional parameters.
259
+ Example: {"prompt": "select_account"} to let users choose their Google account.
239
260
  """
240
261
 
241
262
  settings = GoogleProviderSettings.model_validate(
@@ -245,10 +266,12 @@ class GoogleProvider(OAuthProxy):
245
266
  "client_id": client_id,
246
267
  "client_secret": client_secret,
247
268
  "base_url": base_url,
269
+ "issuer_url": issuer_url,
248
270
  "redirect_path": redirect_path,
249
271
  "required_scopes": required_scopes,
250
272
  "timeout_seconds": timeout_seconds,
251
273
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
274
+ "jwt_signing_key": jwt_signing_key,
252
275
  }.items()
253
276
  if v is not NotSet
254
277
  }
@@ -281,6 +304,18 @@ class GoogleProvider(OAuthProxy):
281
304
  settings.client_secret.get_secret_value() if settings.client_secret else ""
282
305
  )
283
306
 
307
+ # Set Google-specific defaults for extra authorize params
308
+ # access_type=offline ensures refresh tokens are returned
309
+ # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
310
+ google_defaults = {
311
+ "access_type": "offline",
312
+ "prompt": "consent",
313
+ }
314
+ # User-provided params override defaults
315
+ if extra_authorize_params:
316
+ google_defaults.update(extra_authorize_params)
317
+ extra_authorize_params_final = google_defaults
318
+
284
319
  # Initialize OAuth proxy with Google endpoints
285
320
  super().__init__(
286
321
  upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
@@ -290,12 +325,16 @@ class GoogleProvider(OAuthProxy):
290
325
  token_verifier=token_verifier,
291
326
  base_url=settings.base_url,
292
327
  redirect_path=settings.redirect_path,
293
- issuer_url=settings.base_url, # We act as the issuer for client registration
328
+ issuer_url=settings.issuer_url
329
+ or settings.base_url, # Default to base_url if not specified
294
330
  allowed_client_redirect_uris=allowed_client_redirect_uris_final,
295
331
  client_storage=client_storage,
332
+ jwt_signing_key=settings.jwt_signing_key,
333
+ require_authorization_consent=require_authorization_consent,
334
+ extra_authorize_params=extra_authorize_params_final,
296
335
  )
297
336
 
298
- logger.info(
337
+ logger.debug(
299
338
  "Initialized Google OAuth provider for client %s with scopes: %s",
300
339
  settings.client_id,
301
340
  required_scopes_final,
@@ -66,6 +66,22 @@ class InMemoryOAuthProvider(OAuthProvider):
66
66
  return self.clients.get(client_id)
67
67
 
68
68
  async def register_client(self, client_info: OAuthClientInformationFull) -> None:
69
+ # Validate scopes against valid_scopes if configured (matches MCP SDK behavior)
70
+ if (
71
+ client_info.scope is not None
72
+ and self.client_registration_options is not None
73
+ and self.client_registration_options.valid_scopes is not None
74
+ ):
75
+ requested_scopes = set(client_info.scope.split())
76
+ valid_scopes = set(self.client_registration_options.valid_scopes)
77
+ invalid_scopes = requested_scopes - valid_scopes
78
+ if invalid_scopes:
79
+ raise ValueError(
80
+ f"Requested scopes are not valid: {', '.join(invalid_scopes)}"
81
+ )
82
+
83
+ if client_info.client_id is None:
84
+ raise ValueError("client_id is required for client registration")
69
85
  if client_info.client_id in self.clients:
70
86
  # As per RFC 7591, if client_id is already known, it's an update.
71
87
  # For this simple provider, we'll treat it as re-registration.
@@ -91,15 +107,15 @@ class InMemoryOAuthProvider(OAuthProvider):
91
107
  # OAuthClientInformationFull should have a method like validate_redirect_uri
92
108
  # For this test provider, we assume it's valid if it matches one in client_info
93
109
  # The AuthorizationHandler already does robust validation using client.validate_redirect_uri
94
- if params.redirect_uri not in client.redirect_uris:
110
+ if client.redirect_uris and params.redirect_uri not in client.redirect_uris:
95
111
  # This check might be too simplistic if redirect_uris can be patterns
96
112
  # or if params.redirect_uri is None and client has a default.
97
113
  # However, the AuthorizationHandler handles the primary validation.
98
114
  pass # Let's assume AuthorizationHandler did its job.
99
- except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
115
+ except Exception as e: # Replace with specific validation error if client.validate_redirect_uri existed
100
116
  raise AuthorizeError(
101
117
  error="invalid_request", error_description="Invalid redirect_uri."
102
- )
118
+ ) from e
103
119
 
104
120
  auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
105
121
  expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
@@ -110,6 +126,10 @@ class InMemoryOAuthProvider(OAuthProvider):
110
126
  client_allowed_scopes = set(client.scope.split())
111
127
  scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
112
128
 
129
+ if client.client_id is None:
130
+ raise AuthorizeError(
131
+ error="invalid_client", error_description="Client ID is required"
132
+ )
113
133
  auth_code = AuthorizationCode(
114
134
  code=auth_code_value,
115
135
  client_id=client.client_id,
@@ -166,6 +186,8 @@ class InMemoryOAuthProvider(OAuthProvider):
166
186
  time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
167
187
  )
168
188
 
189
+ if client.client_id is None:
190
+ raise TokenError("invalid_client", "Client ID is required")
169
191
  self.access_tokens[access_token_value] = AccessToken(
170
192
  token=access_token_value,
171
193
  client_id=client.client_id,
@@ -236,6 +258,8 @@ class InMemoryOAuthProvider(OAuthProvider):
236
258
  time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
237
259
  )
238
260
 
261
+ if client.client_id is None:
262
+ raise TokenError("invalid_client", "Client ID is required")
239
263
  self.access_tokens[new_access_token_value] = AccessToken(
240
264
  token=new_access_token_value,
241
265
  client_id=client.client_id,
@@ -260,7 +284,7 @@ class InMemoryOAuthProvider(OAuthProvider):
260
284
  scope=" ".join(scopes),
261
285
  )
262
286
 
263
- async def load_access_token(self, token: str) -> AccessToken | None:
287
+ async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override]
264
288
  token_obj = self.access_tokens.get(token)
265
289
  if token_obj:
266
290
  if token_obj.expires_at is not None and token_obj.expires_at < time.time():
@@ -271,7 +295,7 @@ class InMemoryOAuthProvider(OAuthProvider):
271
295
  return token_obj
272
296
  return None
273
297
 
274
- async def verify_token(self, token: str) -> AccessToken | None:
298
+ async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override]
275
299
  """
276
300
  Verify a bearer token and return access info if valid.
277
301
 
@@ -0,0 +1,281 @@
1
+ """OAuth 2.0 Token Introspection (RFC 7662) provider for FastMCP.
2
+
3
+ This module provides token verification for opaque tokens using the OAuth 2.0
4
+ Token Introspection protocol defined in RFC 7662. It allows FastMCP servers to
5
+ validate tokens issued by authorization servers that don't use JWT format.
6
+
7
+ Example:
8
+ ```python
9
+ from fastmcp import FastMCP
10
+ from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
11
+
12
+ # Verify opaque tokens via RFC 7662 introspection
13
+ verifier = IntrospectionTokenVerifier(
14
+ introspection_url="https://auth.example.com/oauth/introspect",
15
+ client_id="your-client-id",
16
+ client_secret="your-client-secret",
17
+ required_scopes=["read", "write"]
18
+ )
19
+
20
+ mcp = FastMCP("My Protected Server", auth=verifier)
21
+ ```
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import time
28
+ from typing import Any
29
+
30
+ import httpx
31
+ from pydantic import AnyHttpUrl, SecretStr, field_validator
32
+ from pydantic_settings import BaseSettings, SettingsConfigDict
33
+
34
+ from fastmcp.server.auth import AccessToken, TokenVerifier
35
+ from fastmcp.settings import ENV_FILE
36
+ from fastmcp.utilities.auth import parse_scopes
37
+ from fastmcp.utilities.logging import get_logger
38
+ from fastmcp.utilities.types import NotSet, NotSetT
39
+
40
+ logger = get_logger(__name__)
41
+
42
+
43
+ class IntrospectionTokenVerifierSettings(BaseSettings):
44
+ """Settings for OAuth 2.0 Token Introspection verification."""
45
+
46
+ model_config = SettingsConfigDict(
47
+ env_prefix="FASTMCP_SERVER_AUTH_INTROSPECTION_",
48
+ env_file=ENV_FILE,
49
+ extra="ignore",
50
+ )
51
+
52
+ introspection_url: str | None = None
53
+ client_id: str | None = None
54
+ client_secret: SecretStr | None = None
55
+ timeout_seconds: int = 10
56
+ required_scopes: list[str] | None = None
57
+ base_url: AnyHttpUrl | str | None = None
58
+
59
+ @field_validator("required_scopes", mode="before")
60
+ @classmethod
61
+ def _parse_scopes(cls, v):
62
+ return parse_scopes(v)
63
+
64
+
65
+ class IntrospectionTokenVerifier(TokenVerifier):
66
+ """
67
+ OAuth 2.0 Token Introspection verifier (RFC 7662).
68
+
69
+ This verifier validates opaque tokens by calling an OAuth 2.0 token introspection
70
+ endpoint. Unlike JWT verification which is stateless, token introspection requires
71
+ a network call to the authorization server for each token validation.
72
+
73
+ The verifier authenticates to the introspection endpoint using HTTP Basic Auth
74
+ with the provided client_id and client_secret, as specified in RFC 7662.
75
+
76
+ Use this when:
77
+ - Your authorization server issues opaque (non-JWT) tokens
78
+ - You need to validate tokens from Auth0, Okta, Keycloak, or other OAuth servers
79
+ - Your tokens require real-time revocation checking
80
+ - Your authorization server supports RFC 7662 introspection
81
+
82
+ Example:
83
+ ```python
84
+ verifier = IntrospectionTokenVerifier(
85
+ introspection_url="https://auth.example.com/oauth/introspect",
86
+ client_id="my-service",
87
+ client_secret="secret-key",
88
+ required_scopes=["api:read"]
89
+ )
90
+ ```
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ introspection_url: str | NotSetT = NotSet,
97
+ client_id: str | NotSetT = NotSet,
98
+ client_secret: str | NotSetT = NotSet,
99
+ timeout_seconds: int | NotSetT = NotSet,
100
+ required_scopes: list[str] | NotSetT | None = NotSet,
101
+ base_url: AnyHttpUrl | str | NotSetT | None = NotSet,
102
+ ):
103
+ """
104
+ Initialize the introspection token verifier.
105
+
106
+ Args:
107
+ introspection_url: URL of the OAuth 2.0 token introspection endpoint
108
+ client_id: OAuth client ID for authenticating to the introspection endpoint
109
+ client_secret: OAuth client secret for authenticating to the introspection endpoint
110
+ timeout_seconds: HTTP request timeout in seconds (default: 10)
111
+ required_scopes: Required scopes for all tokens (optional)
112
+ base_url: Base URL for TokenVerifier protocol
113
+ """
114
+ settings = IntrospectionTokenVerifierSettings.model_validate(
115
+ {
116
+ k: v
117
+ for k, v in {
118
+ "introspection_url": introspection_url,
119
+ "client_id": client_id,
120
+ "client_secret": client_secret,
121
+ "timeout_seconds": timeout_seconds,
122
+ "required_scopes": required_scopes,
123
+ "base_url": base_url,
124
+ }.items()
125
+ if v is not NotSet
126
+ }
127
+ )
128
+
129
+ if not settings.introspection_url:
130
+ raise ValueError(
131
+ "introspection_url is required - set via parameter or "
132
+ "FASTMCP_SERVER_AUTH_INTROSPECTION_INTROSPECTION_URL"
133
+ )
134
+ if not settings.client_id:
135
+ raise ValueError(
136
+ "client_id is required - set via parameter or "
137
+ "FASTMCP_SERVER_AUTH_INTROSPECTION_CLIENT_ID"
138
+ )
139
+ if not settings.client_secret:
140
+ raise ValueError(
141
+ "client_secret is required - set via parameter or "
142
+ "FASTMCP_SERVER_AUTH_INTROSPECTION_CLIENT_SECRET"
143
+ )
144
+
145
+ super().__init__(
146
+ base_url=settings.base_url, required_scopes=settings.required_scopes
147
+ )
148
+
149
+ self.introspection_url = settings.introspection_url
150
+ self.client_id = settings.client_id
151
+ self.client_secret = settings.client_secret.get_secret_value()
152
+ self.timeout_seconds = settings.timeout_seconds
153
+ self.logger = get_logger(__name__)
154
+
155
+ def _create_basic_auth_header(self) -> str:
156
+ """Create HTTP Basic Auth header value from client credentials."""
157
+ credentials = f"{self.client_id}:{self.client_secret}"
158
+ encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
159
+ return f"Basic {encoded}"
160
+
161
+ def _extract_scopes(self, introspection_response: dict[str, Any]) -> list[str]:
162
+ """
163
+ Extract scopes from introspection response.
164
+
165
+ RFC 7662 allows scopes to be returned as either:
166
+ - A space-separated string in the 'scope' field
167
+ - An array of strings in the 'scope' field (less common but valid)
168
+ """
169
+ scope_value = introspection_response.get("scope")
170
+
171
+ if scope_value is None:
172
+ return []
173
+
174
+ # Handle string (space-separated) scopes
175
+ if isinstance(scope_value, str):
176
+ return [s.strip() for s in scope_value.split() if s.strip()]
177
+
178
+ # Handle array of scopes
179
+ if isinstance(scope_value, list):
180
+ return [str(s) for s in scope_value if s]
181
+
182
+ return []
183
+
184
+ async def verify_token(self, token: str) -> AccessToken | None:
185
+ """
186
+ Verify a bearer token using OAuth 2.0 Token Introspection (RFC 7662).
187
+
188
+ This method makes a POST request to the introspection endpoint with the token,
189
+ authenticated using HTTP Basic Auth with the client credentials.
190
+
191
+ Args:
192
+ token: The opaque token string to validate
193
+
194
+ Returns:
195
+ AccessToken object if valid and active, None if invalid, inactive, or expired
196
+ """
197
+ try:
198
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
199
+ # Prepare introspection request per RFC 7662
200
+ auth_header = self._create_basic_auth_header()
201
+
202
+ response = await client.post(
203
+ self.introspection_url,
204
+ data={
205
+ "token": token,
206
+ "token_type_hint": "access_token",
207
+ },
208
+ headers={
209
+ "Authorization": auth_header,
210
+ "Content-Type": "application/x-www-form-urlencoded",
211
+ "Accept": "application/json",
212
+ },
213
+ )
214
+
215
+ # Check for HTTP errors
216
+ if response.status_code != 200:
217
+ self.logger.debug(
218
+ "Token introspection failed: HTTP %d - %s",
219
+ response.status_code,
220
+ response.text[:200] if response.text else "",
221
+ )
222
+ return None
223
+
224
+ introspection_data = response.json()
225
+
226
+ # Check if token is active (required field per RFC 7662)
227
+ if not introspection_data.get("active", False):
228
+ self.logger.debug("Token introspection returned active=false")
229
+ return None
230
+
231
+ # Extract client_id (should be present for active tokens)
232
+ client_id = introspection_data.get(
233
+ "client_id"
234
+ ) or introspection_data.get("sub", "unknown")
235
+
236
+ # Extract expiration time
237
+ exp = introspection_data.get("exp")
238
+ if exp:
239
+ # Validate expiration (belt and suspenders - server should set active=false)
240
+ if exp < time.time():
241
+ self.logger.debug(
242
+ "Token validation failed: expired token for client %s",
243
+ client_id,
244
+ )
245
+ return None
246
+
247
+ # Extract scopes
248
+ scopes = self._extract_scopes(introspection_data)
249
+
250
+ # Check required scopes
251
+ if self.required_scopes:
252
+ token_scopes = set(scopes)
253
+ required_scopes = set(self.required_scopes)
254
+ if not required_scopes.issubset(token_scopes):
255
+ self.logger.debug(
256
+ "Token missing required scopes. Has: %s, Required: %s",
257
+ token_scopes,
258
+ required_scopes,
259
+ )
260
+ return None
261
+
262
+ # Create AccessToken with introspection response data
263
+ return AccessToken(
264
+ token=token,
265
+ client_id=str(client_id),
266
+ scopes=scopes,
267
+ expires_at=int(exp) if exp else None,
268
+ claims=introspection_data, # Store full response for extensibility
269
+ )
270
+
271
+ except httpx.TimeoutException:
272
+ self.logger.debug(
273
+ "Token introspection timed out after %d seconds", self.timeout_seconds
274
+ )
275
+ return None
276
+ except httpx.RequestError as e:
277
+ self.logger.debug("Token introspection request failed: %s", e)
278
+ return None
279
+ except Exception as e:
280
+ self.logger.debug("Token introspection error: %s", e)
281
+ return None