fastmcp 2.12.4__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.
Files changed (72) hide show
  1. fastmcp/cli/cli.py +7 -6
  2. fastmcp/cli/install/claude_code.py +6 -6
  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 +85 -171
  12. fastmcp/client/transports.py +77 -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/resources/types.py +30 -24
  24. fastmcp/server/auth/auth.py +40 -32
  25. fastmcp/server/auth/handlers/authorize.py +324 -0
  26. fastmcp/server/auth/jwt_issuer.py +236 -0
  27. fastmcp/server/auth/middleware.py +96 -0
  28. fastmcp/server/auth/oauth_proxy.py +1256 -242
  29. fastmcp/server/auth/oidc_proxy.py +23 -6
  30. fastmcp/server/auth/providers/auth0.py +40 -21
  31. fastmcp/server/auth/providers/aws.py +29 -3
  32. fastmcp/server/auth/providers/azure.py +178 -127
  33. fastmcp/server/auth/providers/descope.py +4 -6
  34. fastmcp/server/auth/providers/github.py +29 -8
  35. fastmcp/server/auth/providers/google.py +30 -9
  36. fastmcp/server/auth/providers/introspection.py +281 -0
  37. fastmcp/server/auth/providers/jwt.py +8 -2
  38. fastmcp/server/auth/providers/scalekit.py +179 -0
  39. fastmcp/server/auth/providers/supabase.py +172 -0
  40. fastmcp/server/auth/providers/workos.py +32 -14
  41. fastmcp/server/context.py +122 -36
  42. fastmcp/server/http.py +58 -18
  43. fastmcp/server/low_level.py +121 -2
  44. fastmcp/server/middleware/caching.py +469 -0
  45. fastmcp/server/middleware/error_handling.py +6 -2
  46. fastmcp/server/middleware/logging.py +48 -37
  47. fastmcp/server/middleware/middleware.py +28 -15
  48. fastmcp/server/middleware/rate_limiting.py +3 -3
  49. fastmcp/server/middleware/tool_injection.py +116 -0
  50. fastmcp/server/proxy.py +6 -6
  51. fastmcp/server/server.py +683 -207
  52. fastmcp/settings.py +24 -10
  53. fastmcp/tools/tool.py +7 -3
  54. fastmcp/tools/tool_manager.py +30 -112
  55. fastmcp/tools/tool_transform.py +3 -3
  56. fastmcp/utilities/cli.py +62 -22
  57. fastmcp/utilities/components.py +5 -0
  58. fastmcp/utilities/inspect.py +77 -21
  59. fastmcp/utilities/logging.py +118 -8
  60. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  61. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  62. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  63. fastmcp/utilities/tests.py +87 -4
  64. fastmcp/utilities/types.py +1 -1
  65. fastmcp/utilities/ui.py +617 -0
  66. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
  67. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
  68. fastmcp/cli/claude.py +0 -135
  69. fastmcp/utilities/storage.py +0 -204
  70. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
  71. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
  72. {fastmcp-2.12.4.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=".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
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: KVStorage | None = None,
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 of your FastMCP server (for OAuth callbacks)
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 implementation for OAuth client registrations.
238
- Defaults to file-based storage if not specified.
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.base_url, # We act as the issuer for client registration
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.info(
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=".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