fastmcp 2.12.5__py3-none-any.whl → 2.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/cli/cli.py +7 -6
- fastmcp/cli/install/claude_code.py +6 -6
- 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 +85 -171
- fastmcp/client/transports.py +77 -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/resources/types.py +30 -24
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1256 -242
- fastmcp/server/auth/oidc_proxy.py +23 -6
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +178 -127
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +30 -9
- 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 +32 -14
- fastmcp/server/context.py +122 -36
- fastmcp/server/http.py +58 -18
- 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/middleware/tool_injection.py +116 -0
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +683 -207
- fastmcp/settings.py +24 -10
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +62 -22
- 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 +617 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,23 @@ 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,
|
|
222
228
|
):
|
|
223
229
|
"""Initialize Google OAuth provider.
|
|
224
230
|
|
|
225
231
|
Args:
|
|
226
232
|
client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
|
|
227
233
|
client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...")
|
|
228
|
-
base_url: Public URL
|
|
234
|
+
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
|
235
|
+
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
|
236
|
+
to avoid 404s during discovery when mounting under a path.
|
|
229
237
|
redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
|
|
230
238
|
required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
|
|
231
239
|
- "openid" for OpenID Connect (default)
|
|
@@ -234,8 +242,16 @@ class GoogleProvider(OAuthProxy):
|
|
|
234
242
|
timeout_seconds: HTTP request timeout for Google API calls
|
|
235
243
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
236
244
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
237
|
-
client_storage: Storage
|
|
238
|
-
|
|
245
|
+
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
|
246
|
+
If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
|
|
247
|
+
disk store will be encrypted using a key derived from the JWT Signing Key.
|
|
248
|
+
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
|
249
|
+
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
|
250
|
+
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
|
251
|
+
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
|
252
|
+
When True, users see a consent screen before being redirected to Google.
|
|
253
|
+
When False, authorization proceeds directly without user confirmation.
|
|
254
|
+
SECURITY WARNING: Only disable for local development or testing environments.
|
|
239
255
|
"""
|
|
240
256
|
|
|
241
257
|
settings = GoogleProviderSettings.model_validate(
|
|
@@ -245,10 +261,12 @@ class GoogleProvider(OAuthProxy):
|
|
|
245
261
|
"client_id": client_id,
|
|
246
262
|
"client_secret": client_secret,
|
|
247
263
|
"base_url": base_url,
|
|
264
|
+
"issuer_url": issuer_url,
|
|
248
265
|
"redirect_path": redirect_path,
|
|
249
266
|
"required_scopes": required_scopes,
|
|
250
267
|
"timeout_seconds": timeout_seconds,
|
|
251
268
|
"allowed_client_redirect_uris": allowed_client_redirect_uris,
|
|
269
|
+
"jwt_signing_key": jwt_signing_key,
|
|
252
270
|
}.items()
|
|
253
271
|
if v is not NotSet
|
|
254
272
|
}
|
|
@@ -290,12 +308,15 @@ class GoogleProvider(OAuthProxy):
|
|
|
290
308
|
token_verifier=token_verifier,
|
|
291
309
|
base_url=settings.base_url,
|
|
292
310
|
redirect_path=settings.redirect_path,
|
|
293
|
-
issuer_url=settings.
|
|
311
|
+
issuer_url=settings.issuer_url
|
|
312
|
+
or settings.base_url, # Default to base_url if not specified
|
|
294
313
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
295
314
|
client_storage=client_storage,
|
|
315
|
+
jwt_signing_key=settings.jwt_signing_key,
|
|
316
|
+
require_authorization_consent=require_authorization_consent,
|
|
296
317
|
)
|
|
297
318
|
|
|
298
|
-
logger.
|
|
319
|
+
logger.debug(
|
|
299
320
|
"Initialized Google OAuth provider for client %s with scopes: %s",
|
|
300
321
|
settings.client_id,
|
|
301
322
|
required_scopes_final,
|
|
@@ -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
|