fastmcp 2.12.5__py3-none-any.whl → 2.13.0rc1__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/cli/cli.py +6 -6
- fastmcp/cli/install/claude_code.py +3 -3
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +81 -171
- fastmcp/client/transports.py +76 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/jwt_issuer.py +289 -0
- fastmcp/server/auth/oauth_proxy.py +1238 -234
- fastmcp/server/auth/oidc_proxy.py +8 -6
- fastmcp/server/auth/providers/auth0.py +12 -6
- fastmcp/server/auth/providers/aws.py +13 -2
- fastmcp/server/auth/providers/azure.py +137 -124
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +13 -7
- fastmcp/server/auth/providers/google.py +13 -7
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +16 -13
- fastmcp/server/context.py +89 -34
- fastmcp/server/http.py +53 -16
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +638 -183
- fastmcp/settings.py +22 -9
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +22 -108
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +2 -2
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +497 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc1.dist-info}/METADATA +8 -4
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc1.dist-info}/RECORD +66 -62
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc1.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc1.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,13 +42,14 @@ 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
|
|
@@ -198,25 +200,27 @@ class GitHubProvider(OAuthProxy):
|
|
|
198
200
|
client_id: str | NotSetT = NotSet,
|
|
199
201
|
client_secret: str | NotSetT = NotSet,
|
|
200
202
|
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
203
|
+
issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
201
204
|
redirect_path: str | NotSetT = NotSet,
|
|
202
205
|
required_scopes: list[str] | NotSetT = NotSet,
|
|
203
206
|
timeout_seconds: int | NotSetT = NotSet,
|
|
204
207
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
205
|
-
client_storage:
|
|
208
|
+
client_storage: AsyncKeyValue | None = None,
|
|
206
209
|
):
|
|
207
210
|
"""Initialize GitHub OAuth provider.
|
|
208
211
|
|
|
209
212
|
Args:
|
|
210
213
|
client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
|
|
211
214
|
client_secret: GitHub OAuth app client secret
|
|
212
|
-
base_url: Public URL
|
|
215
|
+
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
|
216
|
+
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
|
217
|
+
to avoid 404s during discovery when mounting under a path.
|
|
213
218
|
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
|
|
214
219
|
required_scopes: Required GitHub scopes (defaults to ["user"])
|
|
215
220
|
timeout_seconds: HTTP request timeout for GitHub API calls
|
|
216
221
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
217
222
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
218
|
-
client_storage:
|
|
219
|
-
Defaults to file-based storage if not specified.
|
|
223
|
+
client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided
|
|
220
224
|
"""
|
|
221
225
|
|
|
222
226
|
settings = GitHubProviderSettings.model_validate(
|
|
@@ -226,6 +230,7 @@ class GitHubProvider(OAuthProxy):
|
|
|
226
230
|
"client_id": client_id,
|
|
227
231
|
"client_secret": client_secret,
|
|
228
232
|
"base_url": base_url,
|
|
233
|
+
"issuer_url": issuer_url,
|
|
229
234
|
"redirect_path": redirect_path,
|
|
230
235
|
"required_scopes": required_scopes,
|
|
231
236
|
"timeout_seconds": timeout_seconds,
|
|
@@ -271,7 +276,8 @@ class GitHubProvider(OAuthProxy):
|
|
|
271
276
|
token_verifier=token_verifier,
|
|
272
277
|
base_url=settings.base_url,
|
|
273
278
|
redirect_path=settings.redirect_path,
|
|
274
|
-
issuer_url=settings.
|
|
279
|
+
issuer_url=settings.issuer_url
|
|
280
|
+
or settings.base_url, # Default to base_url if not specified
|
|
275
281
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
276
282
|
client_storage=client_storage,
|
|
277
283
|
)
|
|
@@ -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,13 +44,14 @@ 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
|
|
@@ -214,18 +216,21 @@ class GoogleProvider(OAuthProxy):
|
|
|
214
216
|
client_id: str | NotSetT = NotSet,
|
|
215
217
|
client_secret: str | NotSetT = NotSet,
|
|
216
218
|
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
219
|
+
issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
217
220
|
redirect_path: str | NotSetT = NotSet,
|
|
218
221
|
required_scopes: list[str] | NotSetT = NotSet,
|
|
219
222
|
timeout_seconds: int | NotSetT = NotSet,
|
|
220
223
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
221
|
-
client_storage:
|
|
224
|
+
client_storage: AsyncKeyValue | None = None,
|
|
222
225
|
):
|
|
223
226
|
"""Initialize Google OAuth provider.
|
|
224
227
|
|
|
225
228
|
Args:
|
|
226
229
|
client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
|
|
227
230
|
client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...")
|
|
228
|
-
base_url: Public URL
|
|
231
|
+
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
|
232
|
+
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
|
233
|
+
to avoid 404s during discovery when mounting under a path.
|
|
229
234
|
redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
|
|
230
235
|
required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
|
|
231
236
|
- "openid" for OpenID Connect (default)
|
|
@@ -234,8 +239,7 @@ class GoogleProvider(OAuthProxy):
|
|
|
234
239
|
timeout_seconds: HTTP request timeout for Google API calls
|
|
235
240
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
236
241
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
237
|
-
client_storage:
|
|
238
|
-
Defaults to file-based storage if not specified.
|
|
242
|
+
client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided
|
|
239
243
|
"""
|
|
240
244
|
|
|
241
245
|
settings = GoogleProviderSettings.model_validate(
|
|
@@ -245,6 +249,7 @@ class GoogleProvider(OAuthProxy):
|
|
|
245
249
|
"client_id": client_id,
|
|
246
250
|
"client_secret": client_secret,
|
|
247
251
|
"base_url": base_url,
|
|
252
|
+
"issuer_url": issuer_url,
|
|
248
253
|
"redirect_path": redirect_path,
|
|
249
254
|
"required_scopes": required_scopes,
|
|
250
255
|
"timeout_seconds": timeout_seconds,
|
|
@@ -290,7 +295,8 @@ class GoogleProvider(OAuthProxy):
|
|
|
290
295
|
token_verifier=token_verifier,
|
|
291
296
|
base_url=settings.base_url,
|
|
292
297
|
redirect_path=settings.redirect_path,
|
|
293
|
-
issuer_url=settings.
|
|
298
|
+
issuer_url=settings.issuer_url
|
|
299
|
+
or settings.base_url, # Default to base_url if not specified
|
|
294
300
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
295
301
|
client_storage=client_storage,
|
|
296
302
|
)
|
|
@@ -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] | None | NotSetT = NotSet,
|
|
101
|
+
base_url: AnyHttpUrl | str | None | NotSetT = 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
|
|
@@ -16,6 +16,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
16
16
|
from typing_extensions import TypedDict
|
|
17
17
|
|
|
18
18
|
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
19
|
+
from fastmcp.settings import ENV_FILE
|
|
19
20
|
from fastmcp.utilities.auth import parse_scopes
|
|
20
21
|
from fastmcp.utilities.logging import get_logger
|
|
21
22
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
@@ -143,7 +144,7 @@ class JWTVerifierSettings(BaseSettings):
|
|
|
143
144
|
|
|
144
145
|
model_config = SettingsConfigDict(
|
|
145
146
|
env_prefix="FASTMCP_SERVER_AUTH_JWT_",
|
|
146
|
-
env_file=
|
|
147
|
+
env_file=ENV_FILE,
|
|
147
148
|
extra="ignore",
|
|
148
149
|
)
|
|
149
150
|
|
|
@@ -381,7 +382,12 @@ class JWTVerifier(TokenVerifier):
|
|
|
381
382
|
claims = self.jwt.decode(token, verification_key)
|
|
382
383
|
|
|
383
384
|
# Extract client ID early for logging
|
|
384
|
-
client_id =
|
|
385
|
+
client_id = (
|
|
386
|
+
claims.get("client_id")
|
|
387
|
+
or claims.get("azp")
|
|
388
|
+
or claims.get("sub")
|
|
389
|
+
or "unknown"
|
|
390
|
+
)
|
|
385
391
|
|
|
386
392
|
# Validate expiration
|
|
387
393
|
exp = claims.get("exp")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Scalekit authentication provider for FastMCP.
|
|
2
|
+
|
|
3
|
+
This module provides ScalekitProvider - a complete authentication solution that integrates
|
|
4
|
+
with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server
|
|
5
|
+
authentication for seamless MCP client authentication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import AnyHttpUrl
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
from starlette.responses import JSONResponse
|
|
14
|
+
from starlette.routing import Route
|
|
15
|
+
|
|
16
|
+
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
|
17
|
+
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
18
|
+
from fastmcp.settings import ENV_FILE
|
|
19
|
+
from fastmcp.utilities.logging import get_logger
|
|
20
|
+
from fastmcp.utilities.types import NotSet, NotSetT
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScalekitProviderSettings(BaseSettings):
|
|
26
|
+
model_config = SettingsConfigDict(
|
|
27
|
+
env_prefix="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_",
|
|
28
|
+
env_file=ENV_FILE,
|
|
29
|
+
extra="ignore",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
environment_url: AnyHttpUrl
|
|
33
|
+
client_id: str
|
|
34
|
+
resource_id: str
|
|
35
|
+
mcp_url: AnyHttpUrl
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ScalekitProvider(RemoteAuthProvider):
|
|
39
|
+
"""Scalekit resource server provider for OAuth 2.1 authentication.
|
|
40
|
+
|
|
41
|
+
This provider implements Scalekit integration using resource server pattern.
|
|
42
|
+
FastMCP acts as a protected resource server that validates access tokens issued
|
|
43
|
+
by Scalekit's authorization server.
|
|
44
|
+
|
|
45
|
+
IMPORTANT SETUP REQUIREMENTS:
|
|
46
|
+
|
|
47
|
+
1. Create an MCP Server in Scalekit Dashboard:
|
|
48
|
+
- Go to your [Scalekit Dashboard](https://app.scalekit.com/)
|
|
49
|
+
- Navigate to MCP Servers section
|
|
50
|
+
- Register a new MCP Server with appropriate scopes
|
|
51
|
+
- Ensure the Resource Identifier matches exactly what you configure as MCP URL
|
|
52
|
+
- Note the Resource ID
|
|
53
|
+
|
|
54
|
+
2. Environment Configuration:
|
|
55
|
+
- Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com)
|
|
56
|
+
- Set SCALEKIT_CLIENT_ID from your OAuth application
|
|
57
|
+
- Set SCALEKIT_RESOURCE_ID from your created resource
|
|
58
|
+
- Set MCP_URL to your FastMCP server's public URL
|
|
59
|
+
|
|
60
|
+
For detailed setup instructions, see:
|
|
61
|
+
https://docs.scalekit.com/mcp/overview/
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
```python
|
|
65
|
+
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
|
|
66
|
+
|
|
67
|
+
# Create Scalekit resource server provider
|
|
68
|
+
scalekit_auth = ScalekitProvider(
|
|
69
|
+
environment_url="https://your-env.scalekit.com",
|
|
70
|
+
client_id="sk_client_...",
|
|
71
|
+
resource_id="sk_resource_...",
|
|
72
|
+
mcp_url="https://your-fastmcp-server.com",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Use with FastMCP
|
|
76
|
+
mcp = FastMCP("My App", auth=scalekit_auth)
|
|
77
|
+
```
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
environment_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
84
|
+
client_id: str | NotSetT = NotSet,
|
|
85
|
+
resource_id: str | NotSetT = NotSet,
|
|
86
|
+
mcp_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
87
|
+
token_verifier: TokenVerifier | None = None,
|
|
88
|
+
):
|
|
89
|
+
"""Initialize Scalekit resource server provider.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com")
|
|
93
|
+
client_id: Your Scalekit OAuth client ID
|
|
94
|
+
resource_id: Your Scalekit resource ID
|
|
95
|
+
mcp_url: Public URL of this FastMCP server (used as audience)
|
|
96
|
+
token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit
|
|
97
|
+
"""
|
|
98
|
+
settings = ScalekitProviderSettings.model_validate(
|
|
99
|
+
{
|
|
100
|
+
k: v
|
|
101
|
+
for k, v in {
|
|
102
|
+
"environment_url": environment_url,
|
|
103
|
+
"client_id": client_id,
|
|
104
|
+
"resource_id": resource_id,
|
|
105
|
+
"mcp_url": mcp_url,
|
|
106
|
+
}.items()
|
|
107
|
+
if v is not NotSet
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.environment_url = str(settings.environment_url).rstrip("/")
|
|
112
|
+
self.client_id = settings.client_id
|
|
113
|
+
self.resource_id = settings.resource_id
|
|
114
|
+
self.mcp_url = str(settings.mcp_url)
|
|
115
|
+
|
|
116
|
+
# Create default JWT verifier if none provided
|
|
117
|
+
if token_verifier is None:
|
|
118
|
+
token_verifier = JWTVerifier(
|
|
119
|
+
jwks_uri=f"{self.environment_url}/keys",
|
|
120
|
+
issuer=self.environment_url,
|
|
121
|
+
algorithm="RS256",
|
|
122
|
+
audience=self.mcp_url,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Initialize RemoteAuthProvider with Scalekit as the authorization server
|
|
126
|
+
super().__init__(
|
|
127
|
+
token_verifier=token_verifier,
|
|
128
|
+
authorization_servers=[
|
|
129
|
+
AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}")
|
|
130
|
+
],
|
|
131
|
+
base_url=self.mcp_url,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def get_routes(
|
|
135
|
+
self,
|
|
136
|
+
mcp_path: str | None = None,
|
|
137
|
+
) -> list[Route]:
|
|
138
|
+
"""Get OAuth routes including Scalekit authorization server metadata forwarding.
|
|
139
|
+
|
|
140
|
+
This returns the standard protected resource routes plus an authorization server
|
|
141
|
+
metadata endpoint that forwards Scalekit's OAuth metadata to clients.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
145
|
+
This is used to advertise the resource URL in metadata.
|
|
146
|
+
"""
|
|
147
|
+
# Get the standard protected resource routes from RemoteAuthProvider
|
|
148
|
+
routes = super().get_routes(mcp_path)
|
|
149
|
+
|
|
150
|
+
async def oauth_authorization_server_metadata(request):
|
|
151
|
+
"""Forward Scalekit OAuth authorization server metadata with FastMCP customizations."""
|
|
152
|
+
try:
|
|
153
|
+
async with httpx.AsyncClient() as client:
|
|
154
|
+
response = await client.get(
|
|
155
|
+
f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}"
|
|
156
|
+
)
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
metadata = response.json()
|
|
159
|
+
return JSONResponse(metadata)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error(f"Failed to fetch Scalekit metadata: {e}")
|
|
162
|
+
return JSONResponse(
|
|
163
|
+
{
|
|
164
|
+
"error": "server_error",
|
|
165
|
+
"error_description": f"Failed to fetch Scalekit metadata: {e}",
|
|
166
|
+
},
|
|
167
|
+
status_code=500,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Add Scalekit authorization server metadata forwarding
|
|
171
|
+
routes.append(
|
|
172
|
+
Route(
|
|
173
|
+
"/.well-known/oauth-authorization-server",
|
|
174
|
+
endpoint=oauth_authorization_server_metadata,
|
|
175
|
+
methods=["GET"],
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return routes
|