fastmcp 2.12.5__py3-none-any.whl → 2.13.2__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/__init__.py +2 -2
- fastmcp/cli/cli.py +11 -11
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +115 -217
- fastmcp/client/client.py +105 -39
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +80 -25
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +14 -15
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +2 -2
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +22 -19
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +14 -9
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +70 -43
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1510 -289
- fastmcp/server/auth/oidc_proxy.py +84 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- fastmcp/server/auth/providers/in_memory.py +27 -3
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +35 -17
- fastmcp/server/context.py +177 -51
- fastmcp/server/dependencies.py +39 -12
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +56 -17
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +10 -6
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +725 -242
- fastmcp/settings.py +24 -10
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +70 -23
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -10
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +85 -4
- fastmcp/utilities/types.py +78 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Debug token verifier for testing and special cases.
|
|
2
|
+
|
|
3
|
+
This module provides a flexible token verifier that delegates validation
|
|
4
|
+
to a custom callable. Useful for testing, development, or scenarios where
|
|
5
|
+
standard verification isn't possible (like opaque tokens without introspection).
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
```python
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
|
|
11
|
+
|
|
12
|
+
# Accept all tokens (default - useful for testing)
|
|
13
|
+
auth = DebugTokenVerifier()
|
|
14
|
+
|
|
15
|
+
# Custom sync validation logic
|
|
16
|
+
auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-"))
|
|
17
|
+
|
|
18
|
+
# Custom async validation logic
|
|
19
|
+
async def check_cache(token: str) -> bool:
|
|
20
|
+
return await redis.exists(f"token:{token}")
|
|
21
|
+
|
|
22
|
+
auth = DebugTokenVerifier(validate=check_cache)
|
|
23
|
+
|
|
24
|
+
mcp = FastMCP("My Server", auth=auth)
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import inspect
|
|
31
|
+
from collections.abc import Awaitable, Callable
|
|
32
|
+
|
|
33
|
+
from fastmcp.server.auth import TokenVerifier
|
|
34
|
+
from fastmcp.server.auth.auth import AccessToken
|
|
35
|
+
from fastmcp.utilities.logging import get_logger
|
|
36
|
+
|
|
37
|
+
logger = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DebugTokenVerifier(TokenVerifier):
|
|
41
|
+
"""Token verifier with custom validation logic.
|
|
42
|
+
|
|
43
|
+
This verifier delegates token validation to a user-provided callable.
|
|
44
|
+
By default, it accepts all non-empty tokens (useful for testing).
|
|
45
|
+
|
|
46
|
+
Use cases:
|
|
47
|
+
- Testing: Accept any token without real verification
|
|
48
|
+
- Development: Custom validation logic for prototyping
|
|
49
|
+
- Opaque tokens: When you have tokens with no introspection endpoint
|
|
50
|
+
|
|
51
|
+
WARNING: This bypasses standard security checks. Only use in controlled
|
|
52
|
+
environments or when you understand the security implications.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
validate: Callable[[str], bool]
|
|
58
|
+
| Callable[[str], Awaitable[bool]] = lambda token: True,
|
|
59
|
+
client_id: str = "debug-client",
|
|
60
|
+
scopes: list[str] | None = None,
|
|
61
|
+
required_scopes: list[str] | None = None,
|
|
62
|
+
):
|
|
63
|
+
"""Initialize the debug token verifier.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
validate: Callable that takes a token string and returns True if valid.
|
|
67
|
+
Can be sync or async. Default accepts all tokens.
|
|
68
|
+
client_id: Client ID to assign to validated tokens
|
|
69
|
+
scopes: Scopes to assign to validated tokens
|
|
70
|
+
required_scopes: Required scopes (inherited from TokenVerifier base class)
|
|
71
|
+
"""
|
|
72
|
+
super().__init__(required_scopes=required_scopes)
|
|
73
|
+
self.validate = validate
|
|
74
|
+
self.client_id = client_id
|
|
75
|
+
self.scopes = scopes or []
|
|
76
|
+
|
|
77
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
78
|
+
"""Verify token using custom validation logic.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
token: The token string to validate
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
AccessToken if validation succeeds, None otherwise
|
|
85
|
+
"""
|
|
86
|
+
# Reject empty tokens
|
|
87
|
+
if not token or not token.strip():
|
|
88
|
+
logger.debug("Rejecting empty token")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
# Call validation function and await if result is awaitable
|
|
93
|
+
result = self.validate(token)
|
|
94
|
+
if inspect.isawaitable(result):
|
|
95
|
+
is_valid = await result
|
|
96
|
+
else:
|
|
97
|
+
is_valid = result
|
|
98
|
+
|
|
99
|
+
if not is_valid:
|
|
100
|
+
logger.debug("Token validation failed: callable returned False")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Return valid AccessToken
|
|
104
|
+
return AccessToken(
|
|
105
|
+
token=token,
|
|
106
|
+
client_id=self.client_id,
|
|
107
|
+
scopes=self.scopes,
|
|
108
|
+
expires_at=None, # No expiration
|
|
109
|
+
claims={"token": token}, # Store original token in claims
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.debug("Token validation error: %s", e, exc_info=True)
|
|
114
|
+
return None
|
|
@@ -7,16 +7,18 @@ for seamless MCP client authentication.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
13
|
-
from pydantic import AnyHttpUrl
|
|
13
|
+
from pydantic import AnyHttpUrl, field_validator
|
|
14
14
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
15
15
|
from starlette.responses import JSONResponse
|
|
16
16
|
from starlette.routing import Route
|
|
17
17
|
|
|
18
18
|
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
|
19
19
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
20
|
+
from fastmcp.settings import ENV_FILE
|
|
21
|
+
from fastmcp.utilities.auth import parse_scopes
|
|
20
22
|
from fastmcp.utilities.logging import get_logger
|
|
21
23
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
22
24
|
|
|
@@ -26,13 +28,20 @@ logger = get_logger(__name__)
|
|
|
26
28
|
class DescopeProviderSettings(BaseSettings):
|
|
27
29
|
model_config = SettingsConfigDict(
|
|
28
30
|
env_prefix="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_",
|
|
29
|
-
env_file=
|
|
31
|
+
env_file=ENV_FILE,
|
|
30
32
|
extra="ignore",
|
|
31
33
|
)
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
config_url: AnyHttpUrl | None = None
|
|
36
|
+
project_id: str | None = None
|
|
37
|
+
descope_base_url: AnyHttpUrl | str | None = None
|
|
34
38
|
base_url: AnyHttpUrl
|
|
35
|
-
|
|
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)
|
|
36
45
|
|
|
37
46
|
|
|
38
47
|
class DescopeProvider(RemoteAuthProvider):
|
|
@@ -45,15 +54,15 @@ class DescopeProvider(RemoteAuthProvider):
|
|
|
45
54
|
|
|
46
55
|
IMPORTANT SETUP REQUIREMENTS:
|
|
47
56
|
|
|
48
|
-
1.
|
|
49
|
-
- Go to the [
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
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
|
|
53
62
|
|
|
54
|
-
2. Note your
|
|
55
|
-
- Save your
|
|
56
|
-
-
|
|
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``
|
|
57
66
|
|
|
58
67
|
For detailed setup instructions, see:
|
|
59
68
|
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
|
|
@@ -64,9 +73,8 @@ class DescopeProvider(RemoteAuthProvider):
|
|
|
64
73
|
|
|
65
74
|
# Create Descope metadata provider (JWT verifier created automatically)
|
|
66
75
|
descope_auth = DescopeProvider(
|
|
67
|
-
|
|
76
|
+
config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
|
|
68
77
|
base_url="https://your-fastmcp-server.com",
|
|
69
|
-
descope_base_url="https://api.descope.com",
|
|
70
78
|
)
|
|
71
79
|
|
|
72
80
|
# Use with FastMCP
|
|
@@ -77,57 +85,106 @@ class DescopeProvider(RemoteAuthProvider):
|
|
|
77
85
|
def __init__(
|
|
78
86
|
self,
|
|
79
87
|
*,
|
|
88
|
+
config_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
80
89
|
project_id: str | NotSetT = NotSet,
|
|
81
|
-
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
82
90
|
descope_base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
91
|
+
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
92
|
+
required_scopes: list[str] | NotSetT | None = NotSet,
|
|
83
93
|
token_verifier: TokenVerifier | None = None,
|
|
84
94
|
):
|
|
85
95
|
"""Initialize Descope metadata provider.
|
|
86
96
|
|
|
87
97
|
Args:
|
|
88
|
-
|
|
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.
|
|
89
102
|
base_url: Public URL of this FastMCP server
|
|
90
|
-
|
|
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.
|
|
91
105
|
token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
|
|
92
106
|
"""
|
|
93
107
|
settings = DescopeProviderSettings.model_validate(
|
|
94
108
|
{
|
|
95
109
|
k: v
|
|
96
110
|
for k, v in {
|
|
111
|
+
"config_url": config_url,
|
|
97
112
|
"project_id": project_id,
|
|
98
|
-
"base_url": base_url,
|
|
99
113
|
"descope_base_url": descope_base_url,
|
|
114
|
+
"base_url": base_url,
|
|
115
|
+
"required_scopes": required_scopes,
|
|
100
116
|
}.items()
|
|
101
117
|
if v is not NotSet
|
|
102
118
|
}
|
|
103
119
|
)
|
|
104
120
|
|
|
105
|
-
self.
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
self.base_url = AnyHttpUrl(str(settings.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
|
+
)
|
|
108
167
|
|
|
109
168
|
# Create default JWT verifier if none provided
|
|
110
169
|
if token_verifier is None:
|
|
111
170
|
token_verifier = JWTVerifier(
|
|
112
171
|
jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
|
|
113
|
-
issuer=
|
|
172
|
+
issuer=issuer_url,
|
|
114
173
|
algorithm="RS256",
|
|
115
174
|
audience=self.project_id,
|
|
175
|
+
required_scopes=settings.required_scopes,
|
|
116
176
|
)
|
|
117
177
|
|
|
118
178
|
# Initialize RemoteAuthProvider with Descope as the authorization server
|
|
119
179
|
super().__init__(
|
|
120
180
|
token_verifier=token_verifier,
|
|
121
|
-
authorization_servers=[
|
|
122
|
-
AnyHttpUrl(f"{self.descope_base_url}/v1/apps/{self.project_id}")
|
|
123
|
-
],
|
|
181
|
+
authorization_servers=[AnyHttpUrl(issuer_url)],
|
|
124
182
|
base_url=self.base_url,
|
|
125
183
|
)
|
|
126
184
|
|
|
127
185
|
def get_routes(
|
|
128
186
|
self,
|
|
129
187
|
mcp_path: str | None = None,
|
|
130
|
-
mcp_endpoint: Any | None = None,
|
|
131
188
|
) -> list[Route]:
|
|
132
189
|
"""Get OAuth routes including Descope authorization server metadata forwarding.
|
|
133
190
|
|
|
@@ -136,10 +193,10 @@ class DescopeProvider(RemoteAuthProvider):
|
|
|
136
193
|
|
|
137
194
|
Args:
|
|
138
195
|
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
139
|
-
|
|
196
|
+
This is used to advertise the resource URL in metadata.
|
|
140
197
|
"""
|
|
141
198
|
# Get the standard protected resource routes from RemoteAuthProvider
|
|
142
|
-
routes = super().get_routes(mcp_path
|
|
199
|
+
routes = super().get_routes(mcp_path)
|
|
143
200
|
|
|
144
201
|
async def oauth_authorization_server_metadata(request):
|
|
145
202
|
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""
|
|
@@ -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
|
+
)
|