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.
Files changed (108) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +11 -11
  3. fastmcp/cli/install/claude_code.py +6 -6
  4. fastmcp/cli/install/claude_desktop.py +3 -3
  5. fastmcp/cli/install/cursor.py +18 -12
  6. fastmcp/cli/install/gemini_cli.py +3 -3
  7. fastmcp/cli/install/mcp_json.py +3 -3
  8. fastmcp/cli/run.py +13 -8
  9. fastmcp/client/__init__.py +9 -9
  10. fastmcp/client/auth/oauth.py +115 -217
  11. fastmcp/client/client.py +105 -39
  12. fastmcp/client/logging.py +18 -14
  13. fastmcp/client/oauth_callback.py +85 -171
  14. fastmcp/client/sampling.py +1 -1
  15. fastmcp/client/transports.py +80 -25
  16. fastmcp/contrib/component_manager/__init__.py +1 -1
  17. fastmcp/contrib/component_manager/component_manager.py +2 -2
  18. fastmcp/contrib/component_manager/component_service.py +6 -6
  19. fastmcp/contrib/mcp_mixin/README.md +32 -1
  20. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  21. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  22. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  23. fastmcp/experimental/server/openapi/__init__.py +5 -8
  24. fastmcp/experimental/server/openapi/components.py +11 -7
  25. fastmcp/experimental/server/openapi/routing.py +2 -2
  26. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  27. fastmcp/experimental/utilities/openapi/director.py +14 -15
  28. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  29. fastmcp/experimental/utilities/openapi/models.py +3 -3
  30. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  31. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  32. fastmcp/mcp_config.py +3 -4
  33. fastmcp/prompts/__init__.py +1 -1
  34. fastmcp/prompts/prompt.py +22 -19
  35. fastmcp/prompts/prompt_manager.py +16 -101
  36. fastmcp/resources/__init__.py +5 -5
  37. fastmcp/resources/resource.py +14 -9
  38. fastmcp/resources/resource_manager.py +9 -168
  39. fastmcp/resources/template.py +107 -17
  40. fastmcp/resources/types.py +30 -24
  41. fastmcp/server/__init__.py +1 -1
  42. fastmcp/server/auth/__init__.py +9 -5
  43. fastmcp/server/auth/auth.py +70 -43
  44. fastmcp/server/auth/handlers/authorize.py +326 -0
  45. fastmcp/server/auth/jwt_issuer.py +236 -0
  46. fastmcp/server/auth/middleware.py +96 -0
  47. fastmcp/server/auth/oauth_proxy.py +1510 -289
  48. fastmcp/server/auth/oidc_proxy.py +84 -20
  49. fastmcp/server/auth/providers/auth0.py +40 -21
  50. fastmcp/server/auth/providers/aws.py +29 -3
  51. fastmcp/server/auth/providers/azure.py +312 -131
  52. fastmcp/server/auth/providers/bearer.py +1 -1
  53. fastmcp/server/auth/providers/debug.py +114 -0
  54. fastmcp/server/auth/providers/descope.py +86 -29
  55. fastmcp/server/auth/providers/discord.py +308 -0
  56. fastmcp/server/auth/providers/github.py +29 -8
  57. fastmcp/server/auth/providers/google.py +48 -9
  58. fastmcp/server/auth/providers/in_memory.py +27 -3
  59. fastmcp/server/auth/providers/introspection.py +281 -0
  60. fastmcp/server/auth/providers/jwt.py +48 -31
  61. fastmcp/server/auth/providers/oci.py +233 -0
  62. fastmcp/server/auth/providers/scalekit.py +238 -0
  63. fastmcp/server/auth/providers/supabase.py +188 -0
  64. fastmcp/server/auth/providers/workos.py +35 -17
  65. fastmcp/server/context.py +177 -51
  66. fastmcp/server/dependencies.py +39 -12
  67. fastmcp/server/elicitation.py +1 -1
  68. fastmcp/server/http.py +56 -17
  69. fastmcp/server/low_level.py +121 -2
  70. fastmcp/server/middleware/__init__.py +1 -1
  71. fastmcp/server/middleware/caching.py +476 -0
  72. fastmcp/server/middleware/error_handling.py +14 -10
  73. fastmcp/server/middleware/logging.py +50 -39
  74. fastmcp/server/middleware/middleware.py +29 -16
  75. fastmcp/server/middleware/rate_limiting.py +3 -3
  76. fastmcp/server/middleware/tool_injection.py +116 -0
  77. fastmcp/server/openapi.py +10 -6
  78. fastmcp/server/proxy.py +22 -11
  79. fastmcp/server/server.py +725 -242
  80. fastmcp/settings.py +24 -10
  81. fastmcp/tools/__init__.py +1 -1
  82. fastmcp/tools/tool.py +70 -23
  83. fastmcp/tools/tool_manager.py +30 -112
  84. fastmcp/tools/tool_transform.py +12 -10
  85. fastmcp/utilities/cli.py +67 -28
  86. fastmcp/utilities/components.py +7 -2
  87. fastmcp/utilities/inspect.py +79 -23
  88. fastmcp/utilities/json_schema.py +4 -4
  89. fastmcp/utilities/json_schema_type.py +4 -4
  90. fastmcp/utilities/logging.py +118 -8
  91. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  92. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  93. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  94. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
  95. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  96. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  97. fastmcp/utilities/openapi.py +11 -11
  98. fastmcp/utilities/tests.py +85 -4
  99. fastmcp/utilities/types.py +78 -16
  100. fastmcp/utilities/ui.py +626 -0
  101. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
  102. fastmcp-2.13.2.dist-info/RECORD +144 -0
  103. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  104. fastmcp/cli/claude.py +0 -135
  105. fastmcp/utilities/storage.py +0 -204
  106. fastmcp-2.12.5.dist-info/RECORD +0 -134
  107. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  108. {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 typing import Any
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=".env",
31
+ env_file=ENV_FILE,
30
32
  extra="ignore",
31
33
  )
32
34
 
33
- project_id: str
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
- 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)
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. Enable Dynamic Client Registration in Descope Console:
49
- - Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console
50
- - Click **DCR Settings**
51
- - Enable **Dynamic Client Registration (DCR)**
52
- - 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
53
62
 
54
- 2. Note your Project ID:
55
- - Save your Project ID from [Project Settings](https://app.descope.com/settings/project)
56
- - 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``
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
- project_id="P2abc...123",
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
- 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.
89
102
  base_url: Public URL of this FastMCP server
90
- 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.
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.project_id = settings.project_id
106
- self.base_url = str(settings.base_url).rstrip("/")
107
- self.descope_base_url = str(settings.descope_base_url).rstrip("/")
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=f"{self.descope_base_url}/v1/apps/{self.project_id}",
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
- mcp_endpoint: The MCP endpoint handler to protect with auth
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, mcp_endpoint)
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
+ )