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.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +56 -36
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +7 -16
- fastmcp/cli/install/claude_desktop.py +4 -12
- fastmcp/cli/install/cursor.py +20 -30
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +4 -12
- fastmcp/cli/run.py +15 -94
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +117 -206
- fastmcp/client/client.py +123 -47
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +81 -26
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +7 -7
- fastmcp/contrib/mcp_mixin/README.md +35 -4
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
- 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 +16 -10
- 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 +33 -7
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +32 -27
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +28 -20
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +119 -27
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +80 -47
- 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 +1556 -265
- fastmcp/server/auth/oidc_proxy.py +412 -0
- fastmcp/server/auth/providers/auth0.py +193 -0
- fastmcp/server/auth/providers/aws.py +263 -0
- fastmcp/server/auth/providers/azure.py +314 -129
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +229 -0
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +31 -6
- fastmcp/server/auth/providers/google.py +50 -7
- 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 +37 -15
- fastmcp/server/context.py +194 -67
- fastmcp/server/dependencies.py +56 -16
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +57 -18
- 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 +158 -116
- fastmcp/server/middleware/middleware.py +30 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +15 -7
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +744 -254
- fastmcp/settings.py +65 -15
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +173 -108
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +13 -11
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +21 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +182 -10
- 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 +10 -45
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
- fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +93 -10
- fastmcp/utilities/types.py +87 -21
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -144
- fastmcp-2.12.1.dist-info/RECORD +0 -128
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {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=
|
|
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
|
|
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=
|
|
271
|
-
issuer_url=settings.
|
|
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.
|
|
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=
|
|
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
|
|
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=
|
|
290
|
-
issuer_url=settings.
|
|
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.
|
|
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
|