fastmcp 2.12.5__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.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +11 -11
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +115 -217
- fastmcp/client/client.py +105 -39
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +80 -25
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +14 -15
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +2 -2
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +22 -19
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +14 -9
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +70 -43
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1510 -289
- fastmcp/server/auth/oidc_proxy.py +84 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- fastmcp/server/auth/providers/in_memory.py +27 -3
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +35 -17
- fastmcp/server/context.py +177 -51
- fastmcp/server/dependencies.py +39 -12
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +56 -17
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +10 -6
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +725 -242
- fastmcp/settings.py +24 -10
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +70 -23
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -10
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +85 -4
- fastmcp/utilities/types.py +78 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.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=
|
|
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:
|
|
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
|
|
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
|
|
219
|
-
|
|
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.
|
|
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.
|
|
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=
|
|
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:
|
|
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
|
|
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
|
|
238
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
@@ -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
|