fastmcp 2.13.1__py3-none-any.whl → 2.13.3__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.
@@ -7,8 +7,10 @@ for seamless MCP client authentication.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from urllib.parse import urlparse
11
+
10
12
  import httpx
11
- from pydantic import AnyHttpUrl
13
+ from pydantic import AnyHttpUrl, field_validator
12
14
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
15
  from starlette.responses import JSONResponse
14
16
  from starlette.routing import Route
@@ -16,6 +18,7 @@ from starlette.routing import Route
16
18
  from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
17
19
  from fastmcp.server.auth.providers.jwt import JWTVerifier
18
20
  from fastmcp.settings import ENV_FILE
21
+ from fastmcp.utilities.auth import parse_scopes
19
22
  from fastmcp.utilities.logging import get_logger
20
23
  from fastmcp.utilities.types import NotSet, NotSetT
21
24
 
@@ -29,9 +32,16 @@ class DescopeProviderSettings(BaseSettings):
29
32
  extra="ignore",
30
33
  )
31
34
 
32
- project_id: str
35
+ config_url: AnyHttpUrl | None = None
36
+ project_id: str | None = None
37
+ descope_base_url: AnyHttpUrl | str | None = None
33
38
  base_url: AnyHttpUrl
34
- descope_base_url: AnyHttpUrl = AnyHttpUrl("https://api.descope.com")
39
+ required_scopes: list[str] | None = None
40
+
41
+ @field_validator("required_scopes", mode="before")
42
+ @classmethod
43
+ def _parse_scopes(cls, v):
44
+ return parse_scopes(v)
35
45
 
36
46
 
37
47
  class DescopeProvider(RemoteAuthProvider):
@@ -44,15 +54,15 @@ class DescopeProvider(RemoteAuthProvider):
44
54
 
45
55
  IMPORTANT SETUP REQUIREMENTS:
46
56
 
47
- 1. Enable Dynamic Client Registration in Descope Console:
48
- - Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console
49
- - Click **DCR Settings**
50
- - Enable **Dynamic Client Registration (DCR)**
51
- - Define allowed scopes
57
+ 1. Create an MCP Server in Descope Console:
58
+ - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
59
+ - Create a new MCP Server
60
+ - Ensure that **Dynamic Client Registration (DCR)** is enabled
61
+ - Note your Well-Known URL
52
62
 
53
- 2. Note your Project ID:
54
- - Save your Project ID from [Project Settings](https://app.descope.com/settings/project)
55
- - Example: P2abc...123
63
+ 2. Note your Well-Known URL:
64
+ - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
65
+ - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration``
56
66
 
57
67
  For detailed setup instructions, see:
58
68
  https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
@@ -63,9 +73,8 @@ class DescopeProvider(RemoteAuthProvider):
63
73
 
64
74
  # Create Descope metadata provider (JWT verifier created automatically)
65
75
  descope_auth = DescopeProvider(
66
- project_id="P2abc...123",
76
+ config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
67
77
  base_url="https://your-fastmcp-server.com",
68
- descope_base_url="https://api.descope.com",
69
78
  )
70
79
 
71
80
  # Use with FastMCP
@@ -76,50 +85,100 @@ class DescopeProvider(RemoteAuthProvider):
76
85
  def __init__(
77
86
  self,
78
87
  *,
88
+ config_url: AnyHttpUrl | str | NotSetT = NotSet,
79
89
  project_id: str | NotSetT = NotSet,
80
- base_url: AnyHttpUrl | str | NotSetT = NotSet,
81
90
  descope_base_url: AnyHttpUrl | str | NotSetT = NotSet,
91
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
92
+ required_scopes: list[str] | NotSetT | None = NotSet,
82
93
  token_verifier: TokenVerifier | None = None,
83
94
  ):
84
95
  """Initialize Descope metadata provider.
85
96
 
86
97
  Args:
87
- project_id: Your Descope Project ID (e.g., "P2abc...123")
98
+ config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration")
99
+ This is the new recommended way. If provided, project_id and descope_base_url are ignored.
100
+ project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility.
101
+ descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility.
88
102
  base_url: Public URL of this FastMCP server
89
- descope_base_url: Descope API base URL (defaults to https://api.descope.com)
103
+ required_scopes: Optional list of scopes that must be present in validated tokens.
104
+ These scopes will be included in the protected resource metadata.
90
105
  token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
91
106
  """
92
107
  settings = DescopeProviderSettings.model_validate(
93
108
  {
94
109
  k: v
95
110
  for k, v in {
111
+ "config_url": config_url,
96
112
  "project_id": project_id,
97
- "base_url": base_url,
98
113
  "descope_base_url": descope_base_url,
114
+ "base_url": base_url,
115
+ "required_scopes": required_scopes,
99
116
  }.items()
100
117
  if v is not NotSet
101
118
  }
102
119
  )
103
120
 
104
- self.project_id = settings.project_id
105
121
  self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
106
- self.descope_base_url = str(settings.descope_base_url).rstrip("/")
122
+
123
+ # Determine which API is being used
124
+ if settings.config_url is not None:
125
+ # New API: use config_url
126
+ # Strip /.well-known/openid-configuration from config_url if present
127
+ issuer_url = str(settings.config_url)
128
+ if issuer_url.endswith("/.well-known/openid-configuration"):
129
+ issuer_url = issuer_url[: -len("/.well-known/openid-configuration")]
130
+
131
+ # Parse the issuer URL to extract descope_base_url and project_id for other uses
132
+ parsed_url = urlparse(issuer_url)
133
+ path_parts = parsed_url.path.strip("/").split("/")
134
+
135
+ # Extract project_id from path (format: /v1/apps/agentic/P.../M...)
136
+ if "agentic" in path_parts:
137
+ agentic_index = path_parts.index("agentic")
138
+ if agentic_index + 1 < len(path_parts):
139
+ self.project_id = path_parts[agentic_index + 1]
140
+ else:
141
+ raise ValueError(
142
+ f"Could not extract project_id from config_url: {issuer_url}"
143
+ )
144
+ else:
145
+ raise ValueError(
146
+ f"Could not find 'agentic' in config_url path: {issuer_url}"
147
+ )
148
+
149
+ # Extract descope_base_url (scheme + netloc)
150
+ self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip(
151
+ "/"
152
+ )
153
+ elif settings.project_id is not None and settings.descope_base_url is not None:
154
+ # Old API: use project_id and descope_base_url
155
+ self.project_id = settings.project_id
156
+ descope_base_url_str = str(settings.descope_base_url).rstrip("/")
157
+ # Ensure descope_base_url has a scheme
158
+ if not descope_base_url_str.startswith(("http://", "https://")):
159
+ descope_base_url_str = f"https://{descope_base_url_str}"
160
+ self.descope_base_url = descope_base_url_str
161
+ # Old issuer format
162
+ issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}"
163
+ else:
164
+ raise ValueError(
165
+ "Either config_url (new API) or both project_id and descope_base_url (old API) must be provided"
166
+ )
107
167
 
108
168
  # Create default JWT verifier if none provided
109
169
  if token_verifier is None:
110
170
  token_verifier = JWTVerifier(
111
171
  jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
112
- issuer=f"{self.descope_base_url}/v1/apps/{self.project_id}",
172
+ issuer=issuer_url,
113
173
  algorithm="RS256",
114
174
  audience=self.project_id,
175
+ required_scopes=settings.required_scopes,
115
176
  )
116
177
 
117
178
  # Initialize RemoteAuthProvider with Descope as the authorization server
118
179
  super().__init__(
119
180
  token_verifier=token_verifier,
120
- authorization_servers=[
121
- AnyHttpUrl(f"{self.descope_base_url}/v1/apps/{self.project_id}")
122
- ],
181
+ authorization_servers=[AnyHttpUrl(issuer_url)],
123
182
  base_url=self.base_url,
124
183
  )
125
184
 
@@ -0,0 +1,308 @@
1
+ """Discord OAuth provider for FastMCP.
2
+
3
+ This module provides a complete Discord OAuth integration that's ready to use
4
+ with just a client ID and client secret. It handles all the complexity of
5
+ Discord's OAuth flow, token validation, and user management.
6
+
7
+ Example:
8
+ ```python
9
+ from fastmcp import FastMCP
10
+ from fastmcp.server.auth.providers.discord import DiscordProvider
11
+
12
+ # Simple Discord OAuth protection
13
+ auth = DiscordProvider(
14
+ client_id="your-discord-client-id",
15
+ client_secret="your-discord-client-secret"
16
+ )
17
+
18
+ mcp = FastMCP("My Protected Server", auth=auth)
19
+ ```
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import time
25
+ from datetime import datetime
26
+
27
+ import httpx
28
+ from key_value.aio.protocols import AsyncKeyValue
29
+ from pydantic import AnyHttpUrl, SecretStr, field_validator
30
+ from pydantic_settings import BaseSettings, SettingsConfigDict
31
+
32
+ from fastmcp.server.auth import TokenVerifier
33
+ from fastmcp.server.auth.auth import AccessToken
34
+ from fastmcp.server.auth.oauth_proxy import OAuthProxy
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 DiscordProviderSettings(BaseSettings):
44
+ """Settings for Discord OAuth provider."""
45
+
46
+ model_config = SettingsConfigDict(
47
+ env_prefix="FASTMCP_SERVER_AUTH_DISCORD_",
48
+ env_file=ENV_FILE,
49
+ extra="ignore",
50
+ )
51
+
52
+ client_id: str | None = None
53
+ client_secret: SecretStr | None = None
54
+ base_url: AnyHttpUrl | str | None = None
55
+ issuer_url: AnyHttpUrl | str | None = None
56
+ redirect_path: str | None = None
57
+ required_scopes: list[str] | None = None
58
+ timeout_seconds: int | None = None
59
+ allowed_client_redirect_uris: list[str] | None = None
60
+ jwt_signing_key: str | None = None
61
+
62
+ @field_validator("required_scopes", mode="before")
63
+ @classmethod
64
+ def _parse_scopes(cls, v):
65
+ return parse_scopes(v)
66
+
67
+
68
+ class DiscordTokenVerifier(TokenVerifier):
69
+ """Token verifier for Discord OAuth tokens.
70
+
71
+ Discord OAuth tokens are opaque (not JWTs), so we verify them
72
+ by calling Discord's tokeninfo API to check if they're valid and get user info.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ required_scopes: list[str] | None = None,
79
+ timeout_seconds: int = 10,
80
+ ):
81
+ """Initialize the Discord token verifier.
82
+
83
+ Args:
84
+ required_scopes: Required OAuth scopes (e.g., ['email'])
85
+ timeout_seconds: HTTP request timeout
86
+ """
87
+ super().__init__(required_scopes=required_scopes)
88
+ self.timeout_seconds = timeout_seconds
89
+
90
+ async def verify_token(self, token: str) -> AccessToken | None:
91
+ """Verify Discord OAuth token by calling Discord's tokeninfo API."""
92
+ try:
93
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
94
+ # Use Discord's tokeninfo endpoint to validate the token
95
+ headers = {
96
+ "Authorization": f"Bearer {token}",
97
+ "User-Agent": "FastMCP-Discord-OAuth",
98
+ }
99
+ response = await client.get(
100
+ "https://discord.com/api/oauth2/@me",
101
+ headers=headers,
102
+ )
103
+
104
+ if response.status_code != 200:
105
+ logger.debug(
106
+ "Discord token verification failed: %d",
107
+ response.status_code,
108
+ )
109
+ return None
110
+
111
+ token_info = response.json()
112
+
113
+ # Check if token is expired (Discord returns ISO timestamp)
114
+ expires_str = token_info.get("expires")
115
+ expires_at = None
116
+ if expires_str:
117
+ expires_dt = datetime.fromisoformat(
118
+ expires_str.replace("Z", "+00:00")
119
+ )
120
+ expires_at = int(expires_dt.timestamp())
121
+ if expires_at <= int(time.time()):
122
+ logger.debug("Discord token has expired")
123
+ return None
124
+
125
+ token_scopes = token_info.get("scopes", [])
126
+
127
+ # Check required scopes
128
+ if self.required_scopes:
129
+ token_scopes_set = set(token_scopes)
130
+ required_scopes_set = set(self.required_scopes)
131
+ if not required_scopes_set.issubset(token_scopes_set):
132
+ logger.debug(
133
+ "Discord token missing required scopes. Has %d, needs %d",
134
+ len(token_scopes_set),
135
+ len(required_scopes_set),
136
+ )
137
+ return None
138
+
139
+ user_data = token_info.get("user", {})
140
+ application = token_info.get("application") or {}
141
+ client_id = str(application.get("id", "unknown"))
142
+
143
+ # Create AccessToken with Discord user info
144
+ access_token = AccessToken(
145
+ token=token,
146
+ client_id=client_id,
147
+ scopes=token_scopes,
148
+ expires_at=expires_at,
149
+ claims={
150
+ "sub": user_data.get("id"),
151
+ "username": user_data.get("username"),
152
+ "discriminator": user_data.get("discriminator"),
153
+ "avatar": user_data.get("avatar"),
154
+ "email": user_data.get("email"),
155
+ "verified": user_data.get("verified"),
156
+ "locale": user_data.get("locale"),
157
+ "discord_user": user_data,
158
+ "discord_token_info": token_info,
159
+ },
160
+ )
161
+ logger.debug("Discord token verified successfully")
162
+ return access_token
163
+
164
+ except httpx.RequestError as e:
165
+ logger.debug("Failed to verify Discord token: %s", e)
166
+ return None
167
+ except Exception as e:
168
+ logger.debug("Discord token verification error: %s", e)
169
+ return None
170
+
171
+
172
+ class DiscordProvider(OAuthProxy):
173
+ """Complete Discord OAuth provider for FastMCP.
174
+
175
+ This provider makes it trivial to add Discord OAuth protection to any
176
+ FastMCP server. Just provide your Discord OAuth app credentials and
177
+ a base URL, and you're ready to go.
178
+
179
+ Features:
180
+ - Transparent OAuth proxy to Discord
181
+ - Automatic token validation via Discord's API
182
+ - User information extraction from Discord APIs
183
+ - Minimal configuration required
184
+
185
+ Example:
186
+ ```python
187
+ from fastmcp import FastMCP
188
+ from fastmcp.server.auth.providers.discord import DiscordProvider
189
+
190
+ auth = DiscordProvider(
191
+ client_id="123456789",
192
+ client_secret="discord-client-secret-abc123...",
193
+ base_url="https://my-server.com"
194
+ )
195
+
196
+ mcp = FastMCP("My App", auth=auth)
197
+ ```
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ *,
203
+ client_id: str | NotSetT = NotSet,
204
+ client_secret: str | NotSetT = NotSet,
205
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
206
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
207
+ redirect_path: str | NotSetT = NotSet,
208
+ required_scopes: list[str] | NotSetT = NotSet,
209
+ timeout_seconds: int | NotSetT = NotSet,
210
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
211
+ client_storage: AsyncKeyValue | None = None,
212
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
213
+ require_authorization_consent: bool = True,
214
+ ):
215
+ """Initialize Discord OAuth provider.
216
+
217
+ Args:
218
+ client_id: Discord OAuth client ID (e.g., "123456789")
219
+ client_secret: Discord OAuth client secret (e.g., "S....")
220
+ base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
221
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
222
+ to avoid 404s during discovery when mounting under a path.
223
+ redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback")
224
+ required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include:
225
+ - "identify" for profile info (default)
226
+ - "email" for email access
227
+ - "guilds" for server membership info
228
+ timeout_seconds: HTTP request timeout for Discord API calls
229
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
230
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
231
+ client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
232
+ If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
233
+ disk store will be encrypted using a key derived from the JWT Signing Key.
234
+ jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
235
+ they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
236
+ provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
237
+ require_authorization_consent: Whether to require user consent before authorizing clients (default True).
238
+ When True, users see a consent screen before being redirected to Discord.
239
+ When False, authorization proceeds directly without user confirmation.
240
+ SECURITY WARNING: Only disable for local development or testing environments.
241
+ """
242
+
243
+ settings = DiscordProviderSettings.model_validate(
244
+ {
245
+ k: v
246
+ for k, v in {
247
+ "client_id": client_id,
248
+ "client_secret": client_secret,
249
+ "base_url": base_url,
250
+ "issuer_url": issuer_url,
251
+ "redirect_path": redirect_path,
252
+ "required_scopes": required_scopes,
253
+ "timeout_seconds": timeout_seconds,
254
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
255
+ "jwt_signing_key": jwt_signing_key,
256
+ }.items()
257
+ if v is not NotSet
258
+ }
259
+ )
260
+
261
+ # Validate required settings
262
+ if not settings.client_id:
263
+ raise ValueError(
264
+ "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID"
265
+ )
266
+ if not settings.client_secret:
267
+ raise ValueError(
268
+ "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET"
269
+ )
270
+
271
+ # Apply defaults
272
+ timeout_seconds_final = settings.timeout_seconds or 10
273
+ required_scopes_final = settings.required_scopes or ["identify"]
274
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
275
+
276
+ # Create Discord token verifier
277
+ token_verifier = DiscordTokenVerifier(
278
+ required_scopes=required_scopes_final,
279
+ timeout_seconds=timeout_seconds_final,
280
+ )
281
+
282
+ # Extract secret string from SecretStr
283
+ client_secret_str = (
284
+ settings.client_secret.get_secret_value() if settings.client_secret else ""
285
+ )
286
+
287
+ # Initialize OAuth proxy with Discord endpoints
288
+ super().__init__(
289
+ upstream_authorization_endpoint="https://discord.com/oauth2/authorize",
290
+ upstream_token_endpoint="https://discord.com/api/oauth2/token",
291
+ upstream_client_id=settings.client_id,
292
+ upstream_client_secret=client_secret_str,
293
+ token_verifier=token_verifier,
294
+ base_url=settings.base_url,
295
+ redirect_path=settings.redirect_path,
296
+ issuer_url=settings.issuer_url
297
+ or settings.base_url, # Default to base_url if not specified
298
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
299
+ client_storage=client_storage,
300
+ jwt_signing_key=settings.jwt_signing_key,
301
+ require_authorization_consent=require_authorization_consent,
302
+ )
303
+
304
+ logger.debug(
305
+ "Initialized Discord OAuth provider for client %s with scopes: %s",
306
+ settings.client_id,
307
+ required_scopes_final,
308
+ )
@@ -225,6 +225,7 @@ class GoogleProvider(OAuthProxy):
225
225
  client_storage: AsyncKeyValue | None = None,
226
226
  jwt_signing_key: str | bytes | NotSetT = NotSet,
227
227
  require_authorization_consent: bool = True,
228
+ extra_authorize_params: dict[str, str] | None = None,
228
229
  ):
229
230
  """Initialize Google OAuth provider.
230
231
 
@@ -252,6 +253,10 @@ class GoogleProvider(OAuthProxy):
252
253
  When True, users see a consent screen before being redirected to Google.
253
254
  When False, authorization proceeds directly without user confirmation.
254
255
  SECURITY WARNING: Only disable for local development or testing environments.
256
+ extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
257
+ By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
258
+ refresh tokens are returned. You can override these defaults or add additional parameters.
259
+ Example: {"prompt": "select_account"} to let users choose their Google account.
255
260
  """
256
261
 
257
262
  settings = GoogleProviderSettings.model_validate(
@@ -299,6 +304,18 @@ class GoogleProvider(OAuthProxy):
299
304
  settings.client_secret.get_secret_value() if settings.client_secret else ""
300
305
  )
301
306
 
307
+ # Set Google-specific defaults for extra authorize params
308
+ # access_type=offline ensures refresh tokens are returned
309
+ # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
310
+ google_defaults = {
311
+ "access_type": "offline",
312
+ "prompt": "consent",
313
+ }
314
+ # User-provided params override defaults
315
+ if extra_authorize_params:
316
+ google_defaults.update(extra_authorize_params)
317
+ extra_authorize_params_final = google_defaults
318
+
302
319
  # Initialize OAuth proxy with Google endpoints
303
320
  super().__init__(
304
321
  upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
@@ -314,6 +331,7 @@ class GoogleProvider(OAuthProxy):
314
331
  client_storage=client_storage,
315
332
  jwt_signing_key=settings.jwt_signing_key,
316
333
  require_authorization_consent=require_authorization_consent,
334
+ extra_authorize_params=extra_authorize_params_final,
317
335
  )
318
336
 
319
337
  logger.debug(