fastmcp 2.12.4__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.
Files changed (68) hide show
  1. fastmcp/cli/cli.py +6 -6
  2. fastmcp/cli/install/claude_code.py +3 -3
  3. fastmcp/cli/install/claude_desktop.py +3 -3
  4. fastmcp/cli/install/cursor.py +7 -7
  5. fastmcp/cli/install/gemini_cli.py +3 -3
  6. fastmcp/cli/install/mcp_json.py +3 -3
  7. fastmcp/cli/run.py +13 -8
  8. fastmcp/client/auth/oauth.py +100 -208
  9. fastmcp/client/client.py +11 -11
  10. fastmcp/client/logging.py +18 -14
  11. fastmcp/client/oauth_callback.py +81 -171
  12. fastmcp/client/transports.py +76 -22
  13. fastmcp/contrib/component_manager/component_service.py +6 -6
  14. fastmcp/contrib/mcp_mixin/README.md +32 -1
  15. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  16. fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
  17. fastmcp/experimental/utilities/openapi/parser.py +23 -3
  18. fastmcp/prompts/prompt.py +13 -6
  19. fastmcp/prompts/prompt_manager.py +16 -101
  20. fastmcp/resources/resource.py +13 -6
  21. fastmcp/resources/resource_manager.py +5 -164
  22. fastmcp/resources/template.py +107 -17
  23. fastmcp/server/auth/auth.py +40 -32
  24. fastmcp/server/auth/jwt_issuer.py +289 -0
  25. fastmcp/server/auth/oauth_proxy.py +1238 -234
  26. fastmcp/server/auth/oidc_proxy.py +8 -6
  27. fastmcp/server/auth/providers/auth0.py +12 -6
  28. fastmcp/server/auth/providers/aws.py +13 -2
  29. fastmcp/server/auth/providers/azure.py +137 -124
  30. fastmcp/server/auth/providers/descope.py +4 -6
  31. fastmcp/server/auth/providers/github.py +13 -7
  32. fastmcp/server/auth/providers/google.py +13 -7
  33. fastmcp/server/auth/providers/introspection.py +281 -0
  34. fastmcp/server/auth/providers/jwt.py +8 -2
  35. fastmcp/server/auth/providers/scalekit.py +179 -0
  36. fastmcp/server/auth/providers/supabase.py +172 -0
  37. fastmcp/server/auth/providers/workos.py +16 -13
  38. fastmcp/server/context.py +89 -34
  39. fastmcp/server/http.py +53 -16
  40. fastmcp/server/low_level.py +121 -2
  41. fastmcp/server/middleware/caching.py +469 -0
  42. fastmcp/server/middleware/error_handling.py +6 -2
  43. fastmcp/server/middleware/logging.py +48 -37
  44. fastmcp/server/middleware/middleware.py +28 -15
  45. fastmcp/server/middleware/rate_limiting.py +3 -3
  46. fastmcp/server/proxy.py +6 -6
  47. fastmcp/server/server.py +638 -183
  48. fastmcp/settings.py +22 -9
  49. fastmcp/tools/tool.py +7 -3
  50. fastmcp/tools/tool_manager.py +22 -108
  51. fastmcp/tools/tool_transform.py +3 -3
  52. fastmcp/utilities/cli.py +2 -2
  53. fastmcp/utilities/components.py +5 -0
  54. fastmcp/utilities/inspect.py +77 -21
  55. fastmcp/utilities/logging.py +118 -8
  56. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  57. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  58. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  59. fastmcp/utilities/tests.py +87 -4
  60. fastmcp/utilities/types.py +1 -1
  61. fastmcp/utilities/ui.py +497 -0
  62. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/METADATA +8 -4
  63. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/RECORD +66 -62
  64. fastmcp/cli/claude.py +0 -135
  65. fastmcp/utilities/storage.py +0 -204
  66. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/WHEEL +0 -0
  67. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0rc1.dist-info}/entry_points.txt +0 -0
  68. {fastmcp-2.12.4.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=".env",
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: KVStorage | None = None,
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 of your FastMCP server (for OAuth callbacks)
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: Storage implementation for OAuth client registrations.
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.base_url, # We act as the issuer for client registration
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=".env",
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: KVStorage | None = None,
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 of your FastMCP server (for OAuth callbacks)
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: Storage implementation for OAuth client registrations.
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.base_url, # We act as the issuer for client registration
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=".env",
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 = claims.get("client_id") or claims.get("sub") or "unknown"
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