fastmcp 2.12.1__py3-none-any.whl → 2.13.2__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 (109) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +56 -36
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +7 -16
  5. fastmcp/cli/install/claude_desktop.py +4 -12
  6. fastmcp/cli/install/cursor.py +20 -30
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +4 -12
  9. fastmcp/cli/run.py +15 -94
  10. fastmcp/client/__init__.py +9 -9
  11. fastmcp/client/auth/oauth.py +117 -206
  12. fastmcp/client/client.py +123 -47
  13. fastmcp/client/elicitation.py +6 -1
  14. fastmcp/client/logging.py +18 -14
  15. fastmcp/client/oauth_callback.py +85 -171
  16. fastmcp/client/sampling.py +1 -1
  17. fastmcp/client/transports.py +81 -26
  18. fastmcp/contrib/component_manager/__init__.py +1 -1
  19. fastmcp/contrib/component_manager/component_manager.py +2 -2
  20. fastmcp/contrib/component_manager/component_service.py +7 -7
  21. fastmcp/contrib/mcp_mixin/README.md +35 -4
  22. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  23. fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
  24. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  25. fastmcp/experimental/server/openapi/__init__.py +5 -8
  26. fastmcp/experimental/server/openapi/components.py +11 -7
  27. fastmcp/experimental/server/openapi/routing.py +2 -2
  28. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  29. fastmcp/experimental/utilities/openapi/director.py +16 -10
  30. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  31. fastmcp/experimental/utilities/openapi/models.py +3 -3
  32. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  33. fastmcp/experimental/utilities/openapi/schemas.py +33 -7
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +32 -27
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +28 -20
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +119 -27
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -5
  45. fastmcp/server/auth/auth.py +80 -47
  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 +1556 -265
  50. fastmcp/server/auth/oidc_proxy.py +412 -0
  51. fastmcp/server/auth/providers/auth0.py +193 -0
  52. fastmcp/server/auth/providers/aws.py +263 -0
  53. fastmcp/server/auth/providers/azure.py +314 -129
  54. fastmcp/server/auth/providers/bearer.py +1 -1
  55. fastmcp/server/auth/providers/debug.py +114 -0
  56. fastmcp/server/auth/providers/descope.py +229 -0
  57. fastmcp/server/auth/providers/discord.py +308 -0
  58. fastmcp/server/auth/providers/github.py +31 -6
  59. fastmcp/server/auth/providers/google.py +50 -7
  60. fastmcp/server/auth/providers/in_memory.py +27 -3
  61. fastmcp/server/auth/providers/introspection.py +281 -0
  62. fastmcp/server/auth/providers/jwt.py +48 -31
  63. fastmcp/server/auth/providers/oci.py +233 -0
  64. fastmcp/server/auth/providers/scalekit.py +238 -0
  65. fastmcp/server/auth/providers/supabase.py +188 -0
  66. fastmcp/server/auth/providers/workos.py +37 -15
  67. fastmcp/server/context.py +194 -67
  68. fastmcp/server/dependencies.py +56 -16
  69. fastmcp/server/elicitation.py +1 -1
  70. fastmcp/server/http.py +57 -18
  71. fastmcp/server/low_level.py +121 -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 +158 -116
  76. fastmcp/server/middleware/middleware.py +30 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi.py +15 -7
  80. fastmcp/server/proxy.py +22 -11
  81. fastmcp/server/server.py +744 -254
  82. fastmcp/settings.py +65 -15
  83. fastmcp/tools/__init__.py +1 -1
  84. fastmcp/tools/tool.py +173 -108
  85. fastmcp/tools/tool_manager.py +30 -112
  86. fastmcp/tools/tool_transform.py +13 -11
  87. fastmcp/utilities/cli.py +67 -28
  88. fastmcp/utilities/components.py +7 -2
  89. fastmcp/utilities/inspect.py +79 -23
  90. fastmcp/utilities/json_schema.py +21 -4
  91. fastmcp/utilities/json_schema_type.py +4 -4
  92. fastmcp/utilities/logging.py +182 -10
  93. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  94. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  95. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
  96. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
  97. fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
  98. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  99. fastmcp/utilities/openapi.py +11 -11
  100. fastmcp/utilities/tests.py +93 -10
  101. fastmcp/utilities/types.py +87 -21
  102. fastmcp/utilities/ui.py +626 -0
  103. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
  104. fastmcp-2.13.2.dist-info/RECORD +144 -0
  105. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  106. fastmcp/cli/claude.py +0 -144
  107. fastmcp-2.12.1.dist-info/RECORD +0 -128
  108. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  109. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -22,12 +22,14 @@ 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
35
  from fastmcp.utilities.types import NotSet, NotSetT
@@ -40,17 +42,19 @@ class GitHubProviderSettings(BaseSettings):
40
42
 
41
43
  model_config = SettingsConfigDict(
42
44
  env_prefix="FASTMCP_SERVER_AUTH_GITHUB_",
43
- env_file=".env",
45
+ env_file=ENV_FILE,
44
46
  extra="ignore",
45
47
  )
46
48
 
47
49
  client_id: str | None = None
48
50
  client_secret: SecretStr | None = None
49
51
  base_url: AnyHttpUrl | str | None = None
52
+ issuer_url: AnyHttpUrl | str | None = None
50
53
  redirect_path: str | None = None
51
54
  required_scopes: list[str] | None = None
52
55
  timeout_seconds: int | None = None
53
56
  allowed_client_redirect_uris: list[str] | None = None
57
+ jwt_signing_key: str | None = None
54
58
 
55
59
  @field_validator("required_scopes", mode="before")
56
60
  @classmethod
@@ -197,22 +201,38 @@ class GitHubProvider(OAuthProxy):
197
201
  client_id: str | NotSetT = NotSet,
198
202
  client_secret: str | NotSetT = NotSet,
199
203
  base_url: AnyHttpUrl | str | NotSetT = NotSet,
204
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
200
205
  redirect_path: str | NotSetT = NotSet,
201
206
  required_scopes: list[str] | NotSetT = NotSet,
202
207
  timeout_seconds: int | NotSetT = NotSet,
203
208
  allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
209
+ client_storage: AsyncKeyValue | None = None,
210
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
211
+ require_authorization_consent: bool = True,
204
212
  ):
205
213
  """Initialize GitHub OAuth provider.
206
214
 
207
215
  Args:
208
216
  client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
209
217
  client_secret: GitHub OAuth app client secret
210
- 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.
211
221
  redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
212
222
  required_scopes: Required GitHub scopes (defaults to ["user"])
213
223
  timeout_seconds: HTTP request timeout for GitHub API calls
214
224
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
215
225
  If None (default), all URIs are allowed. If empty list, no URIs are allowed.
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.
216
236
  """
217
237
 
218
238
  settings = GitHubProviderSettings.model_validate(
@@ -222,10 +242,12 @@ class GitHubProvider(OAuthProxy):
222
242
  "client_id": client_id,
223
243
  "client_secret": client_secret,
224
244
  "base_url": base_url,
245
+ "issuer_url": issuer_url,
225
246
  "redirect_path": redirect_path,
226
247
  "required_scopes": required_scopes,
227
248
  "timeout_seconds": timeout_seconds,
228
249
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
250
+ "jwt_signing_key": jwt_signing_key,
229
251
  }.items()
230
252
  if v is not NotSet
231
253
  }
@@ -243,7 +265,6 @@ class GitHubProvider(OAuthProxy):
243
265
 
244
266
  # Apply defaults
245
267
 
246
- redirect_path_final = settings.redirect_path or "/auth/callback"
247
268
  timeout_seconds_final = settings.timeout_seconds or 10
248
269
  required_scopes_final = settings.required_scopes or ["user"]
249
270
  allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
@@ -267,12 +288,16 @@ class GitHubProvider(OAuthProxy):
267
288
  upstream_client_secret=client_secret_str,
268
289
  token_verifier=token_verifier,
269
290
  base_url=settings.base_url,
270
- redirect_path=redirect_path_final,
271
- issuer_url=settings.base_url, # We act as the issuer for client registration
291
+ redirect_path=settings.redirect_path,
292
+ issuer_url=settings.issuer_url
293
+ or settings.base_url, # Default to base_url if not specified
272
294
  allowed_client_redirect_uris=allowed_client_redirect_uris_final,
295
+ client_storage=client_storage,
296
+ jwt_signing_key=settings.jwt_signing_key,
297
+ require_authorization_consent=require_authorization_consent,
273
298
  )
274
299
 
275
- logger.info(
300
+ logger.debug(
276
301
  "Initialized GitHub OAuth provider for client %s with scopes: %s",
277
302
  settings.client_id,
278
303
  required_scopes_final,
@@ -24,12 +24,14 @@ 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
37
  from fastmcp.utilities.types import NotSet, NotSetT
@@ -42,17 +44,19 @@ class GoogleProviderSettings(BaseSettings):
42
44
 
43
45
  model_config = SettingsConfigDict(
44
46
  env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_",
45
- env_file=".env",
47
+ env_file=ENV_FILE,
46
48
  extra="ignore",
47
49
  )
48
50
 
49
51
  client_id: str | None = None
50
52
  client_secret: SecretStr | None = None
51
53
  base_url: AnyHttpUrl | str | None = None
54
+ issuer_url: AnyHttpUrl | str | None = None
52
55
  redirect_path: str | None = None
53
56
  required_scopes: list[str] | None = None
54
57
  timeout_seconds: int | None = None
55
58
  allowed_client_redirect_uris: list[str] | None = None
59
+ jwt_signing_key: str | None = None
56
60
 
57
61
  @field_validator("required_scopes", mode="before")
58
62
  @classmethod
@@ -76,7 +80,7 @@ class GoogleTokenVerifier(TokenVerifier):
76
80
  """Initialize the Google token verifier.
77
81
 
78
82
  Args:
79
- 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'])
80
84
  timeout_seconds: HTTP request timeout
81
85
  """
82
86
  super().__init__(required_scopes=required_scopes)
@@ -213,17 +217,24 @@ class GoogleProvider(OAuthProxy):
213
217
  client_id: str | NotSetT = NotSet,
214
218
  client_secret: str | NotSetT = NotSet,
215
219
  base_url: AnyHttpUrl | str | NotSetT = NotSet,
220
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
216
221
  redirect_path: str | NotSetT = NotSet,
217
222
  required_scopes: list[str] | NotSetT = NotSet,
218
223
  timeout_seconds: int | NotSetT = NotSet,
219
224
  allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
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,
220
229
  ):
221
230
  """Initialize Google OAuth provider.
222
231
 
223
232
  Args:
224
233
  client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
225
234
  client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...")
226
- 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.
227
238
  redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
228
239
  required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
229
240
  - "openid" for OpenID Connect (default)
@@ -232,6 +243,20 @@ class GoogleProvider(OAuthProxy):
232
243
  timeout_seconds: HTTP request timeout for Google API calls
233
244
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
234
245
  If None (default), all URIs are allowed. If empty list, no URIs are allowed.
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.
235
260
  """
236
261
 
237
262
  settings = GoogleProviderSettings.model_validate(
@@ -241,10 +266,12 @@ class GoogleProvider(OAuthProxy):
241
266
  "client_id": client_id,
242
267
  "client_secret": client_secret,
243
268
  "base_url": base_url,
269
+ "issuer_url": issuer_url,
244
270
  "redirect_path": redirect_path,
245
271
  "required_scopes": required_scopes,
246
272
  "timeout_seconds": timeout_seconds,
247
273
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
274
+ "jwt_signing_key": jwt_signing_key,
248
275
  }.items()
249
276
  if v is not NotSet
250
277
  }
@@ -261,7 +288,6 @@ class GoogleProvider(OAuthProxy):
261
288
  )
262
289
 
263
290
  # Apply defaults
264
- redirect_path_final = settings.redirect_path or "/auth/callback"
265
291
  timeout_seconds_final = settings.timeout_seconds or 10
266
292
  # Google requires at least one scope - openid is the minimal OIDC scope
267
293
  required_scopes_final = settings.required_scopes or ["openid"]
@@ -278,6 +304,18 @@ class GoogleProvider(OAuthProxy):
278
304
  settings.client_secret.get_secret_value() if settings.client_secret else ""
279
305
  )
280
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
+
281
319
  # Initialize OAuth proxy with Google endpoints
282
320
  super().__init__(
283
321
  upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
@@ -286,12 +324,17 @@ class GoogleProvider(OAuthProxy):
286
324
  upstream_client_secret=client_secret_str,
287
325
  token_verifier=token_verifier,
288
326
  base_url=settings.base_url,
289
- redirect_path=redirect_path_final,
290
- issuer_url=settings.base_url, # We act as the issuer for client registration
327
+ redirect_path=settings.redirect_path,
328
+ issuer_url=settings.issuer_url
329
+ or settings.base_url, # Default to base_url if not specified
291
330
  allowed_client_redirect_uris=allowed_client_redirect_uris_final,
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,
292
335
  )
293
336
 
294
- logger.info(
337
+ logger.debug(
295
338
  "Initialized Google OAuth provider for client %s with scopes: %s",
296
339
  settings.client_id,
297
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,
@@ -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