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.
- fastmcp/server/auth/oauth_proxy.py +152 -85
- fastmcp/server/auth/oidc_proxy.py +31 -3
- fastmcp/server/auth/providers/azure.py +96 -10
- fastmcp/server/auth/providers/descope.py +82 -23
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/google.py +18 -0
- fastmcp/server/auth/providers/scalekit.py +76 -17
- fastmcp/server/dependencies.py +26 -4
- fastmcp/server/proxy.py +10 -0
- fastmcp/server/server.py +4 -1
- fastmcp/tools/tool.py +19 -1
- fastmcp/tools/tool_transform.py +3 -1
- fastmcp/utilities/types.py +49 -0
- fastmcp/utilities/ui.py +11 -2
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/METADATA +4 -3
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/RECORD +19 -18
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/WHEEL +1 -1
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
48
|
-
- Go to the [
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
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
|
|
54
|
-
- Save your
|
|
55
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|