fastmcp 2.14.4__py3-none-any.whl → 3.0.0b1__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 (175) hide show
  1. fastmcp/_vendor/__init__.py +1 -0
  2. fastmcp/_vendor/docket_di/README.md +7 -0
  3. fastmcp/_vendor/docket_di/__init__.py +163 -0
  4. fastmcp/cli/cli.py +112 -28
  5. fastmcp/cli/install/claude_code.py +1 -5
  6. fastmcp/cli/install/claude_desktop.py +1 -5
  7. fastmcp/cli/install/cursor.py +1 -5
  8. fastmcp/cli/install/gemini_cli.py +1 -5
  9. fastmcp/cli/install/mcp_json.py +1 -6
  10. fastmcp/cli/run.py +146 -5
  11. fastmcp/client/__init__.py +7 -9
  12. fastmcp/client/auth/oauth.py +18 -17
  13. fastmcp/client/client.py +100 -870
  14. fastmcp/client/elicitation.py +1 -1
  15. fastmcp/client/mixins/__init__.py +13 -0
  16. fastmcp/client/mixins/prompts.py +295 -0
  17. fastmcp/client/mixins/resources.py +325 -0
  18. fastmcp/client/mixins/task_management.py +157 -0
  19. fastmcp/client/mixins/tools.py +397 -0
  20. fastmcp/client/sampling/handlers/anthropic.py +2 -2
  21. fastmcp/client/sampling/handlers/openai.py +1 -1
  22. fastmcp/client/tasks.py +3 -3
  23. fastmcp/client/telemetry.py +47 -0
  24. fastmcp/client/transports/__init__.py +38 -0
  25. fastmcp/client/transports/base.py +82 -0
  26. fastmcp/client/transports/config.py +170 -0
  27. fastmcp/client/transports/http.py +145 -0
  28. fastmcp/client/transports/inference.py +154 -0
  29. fastmcp/client/transports/memory.py +90 -0
  30. fastmcp/client/transports/sse.py +89 -0
  31. fastmcp/client/transports/stdio.py +543 -0
  32. fastmcp/contrib/component_manager/README.md +4 -10
  33. fastmcp/contrib/component_manager/__init__.py +1 -2
  34. fastmcp/contrib/component_manager/component_manager.py +95 -160
  35. fastmcp/contrib/component_manager/example.py +1 -1
  36. fastmcp/contrib/mcp_mixin/example.py +4 -4
  37. fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
  38. fastmcp/decorators.py +41 -0
  39. fastmcp/dependencies.py +12 -1
  40. fastmcp/exceptions.py +4 -0
  41. fastmcp/experimental/server/openapi/__init__.py +18 -15
  42. fastmcp/mcp_config.py +13 -4
  43. fastmcp/prompts/__init__.py +6 -3
  44. fastmcp/prompts/function_prompt.py +465 -0
  45. fastmcp/prompts/prompt.py +321 -271
  46. fastmcp/resources/__init__.py +5 -3
  47. fastmcp/resources/function_resource.py +335 -0
  48. fastmcp/resources/resource.py +325 -115
  49. fastmcp/resources/template.py +215 -43
  50. fastmcp/resources/types.py +27 -12
  51. fastmcp/server/__init__.py +2 -2
  52. fastmcp/server/auth/__init__.py +14 -0
  53. fastmcp/server/auth/auth.py +30 -10
  54. fastmcp/server/auth/authorization.py +190 -0
  55. fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
  56. fastmcp/server/auth/oauth_proxy/consent.py +361 -0
  57. fastmcp/server/auth/oauth_proxy/models.py +178 -0
  58. fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
  59. fastmcp/server/auth/oauth_proxy/ui.py +277 -0
  60. fastmcp/server/auth/oidc_proxy.py +2 -2
  61. fastmcp/server/auth/providers/auth0.py +24 -94
  62. fastmcp/server/auth/providers/aws.py +26 -95
  63. fastmcp/server/auth/providers/azure.py +41 -129
  64. fastmcp/server/auth/providers/descope.py +18 -49
  65. fastmcp/server/auth/providers/discord.py +25 -86
  66. fastmcp/server/auth/providers/github.py +23 -87
  67. fastmcp/server/auth/providers/google.py +24 -87
  68. fastmcp/server/auth/providers/introspection.py +60 -79
  69. fastmcp/server/auth/providers/jwt.py +30 -67
  70. fastmcp/server/auth/providers/oci.py +47 -110
  71. fastmcp/server/auth/providers/scalekit.py +23 -61
  72. fastmcp/server/auth/providers/supabase.py +18 -47
  73. fastmcp/server/auth/providers/workos.py +34 -127
  74. fastmcp/server/context.py +372 -419
  75. fastmcp/server/dependencies.py +541 -251
  76. fastmcp/server/elicitation.py +20 -18
  77. fastmcp/server/event_store.py +3 -3
  78. fastmcp/server/http.py +16 -6
  79. fastmcp/server/lifespan.py +198 -0
  80. fastmcp/server/low_level.py +92 -2
  81. fastmcp/server/middleware/__init__.py +5 -1
  82. fastmcp/server/middleware/authorization.py +312 -0
  83. fastmcp/server/middleware/caching.py +101 -54
  84. fastmcp/server/middleware/middleware.py +6 -9
  85. fastmcp/server/middleware/ping.py +70 -0
  86. fastmcp/server/middleware/tool_injection.py +2 -2
  87. fastmcp/server/mixins/__init__.py +7 -0
  88. fastmcp/server/mixins/lifespan.py +217 -0
  89. fastmcp/server/mixins/mcp_operations.py +392 -0
  90. fastmcp/server/mixins/transport.py +342 -0
  91. fastmcp/server/openapi/__init__.py +41 -21
  92. fastmcp/server/openapi/components.py +16 -339
  93. fastmcp/server/openapi/routing.py +34 -118
  94. fastmcp/server/openapi/server.py +67 -392
  95. fastmcp/server/providers/__init__.py +71 -0
  96. fastmcp/server/providers/aggregate.py +261 -0
  97. fastmcp/server/providers/base.py +578 -0
  98. fastmcp/server/providers/fastmcp_provider.py +674 -0
  99. fastmcp/server/providers/filesystem.py +226 -0
  100. fastmcp/server/providers/filesystem_discovery.py +327 -0
  101. fastmcp/server/providers/local_provider/__init__.py +11 -0
  102. fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
  103. fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
  104. fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
  105. fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
  106. fastmcp/server/providers/local_provider/local_provider.py +465 -0
  107. fastmcp/server/providers/openapi/__init__.py +39 -0
  108. fastmcp/server/providers/openapi/components.py +332 -0
  109. fastmcp/server/providers/openapi/provider.py +405 -0
  110. fastmcp/server/providers/openapi/routing.py +109 -0
  111. fastmcp/server/providers/proxy.py +867 -0
  112. fastmcp/server/providers/skills/__init__.py +59 -0
  113. fastmcp/server/providers/skills/_common.py +101 -0
  114. fastmcp/server/providers/skills/claude_provider.py +44 -0
  115. fastmcp/server/providers/skills/directory_provider.py +153 -0
  116. fastmcp/server/providers/skills/skill_provider.py +432 -0
  117. fastmcp/server/providers/skills/vendor_providers.py +142 -0
  118. fastmcp/server/providers/wrapped_provider.py +140 -0
  119. fastmcp/server/proxy.py +34 -700
  120. fastmcp/server/sampling/run.py +341 -2
  121. fastmcp/server/sampling/sampling_tool.py +4 -3
  122. fastmcp/server/server.py +1214 -2171
  123. fastmcp/server/tasks/__init__.py +2 -1
  124. fastmcp/server/tasks/capabilities.py +13 -1
  125. fastmcp/server/tasks/config.py +66 -3
  126. fastmcp/server/tasks/handlers.py +65 -273
  127. fastmcp/server/tasks/keys.py +4 -6
  128. fastmcp/server/tasks/requests.py +474 -0
  129. fastmcp/server/tasks/routing.py +76 -0
  130. fastmcp/server/tasks/subscriptions.py +20 -11
  131. fastmcp/server/telemetry.py +131 -0
  132. fastmcp/server/transforms/__init__.py +244 -0
  133. fastmcp/server/transforms/namespace.py +193 -0
  134. fastmcp/server/transforms/prompts_as_tools.py +175 -0
  135. fastmcp/server/transforms/resources_as_tools.py +190 -0
  136. fastmcp/server/transforms/tool_transform.py +96 -0
  137. fastmcp/server/transforms/version_filter.py +124 -0
  138. fastmcp/server/transforms/visibility.py +526 -0
  139. fastmcp/settings.py +34 -96
  140. fastmcp/telemetry.py +122 -0
  141. fastmcp/tools/__init__.py +10 -3
  142. fastmcp/tools/function_parsing.py +201 -0
  143. fastmcp/tools/function_tool.py +467 -0
  144. fastmcp/tools/tool.py +215 -362
  145. fastmcp/tools/tool_transform.py +38 -21
  146. fastmcp/utilities/async_utils.py +69 -0
  147. fastmcp/utilities/components.py +152 -91
  148. fastmcp/utilities/inspect.py +8 -20
  149. fastmcp/utilities/json_schema.py +12 -5
  150. fastmcp/utilities/json_schema_type.py +17 -15
  151. fastmcp/utilities/lifespan.py +56 -0
  152. fastmcp/utilities/logging.py +12 -4
  153. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  154. fastmcp/utilities/openapi/parser.py +3 -3
  155. fastmcp/utilities/pagination.py +80 -0
  156. fastmcp/utilities/skills.py +253 -0
  157. fastmcp/utilities/tests.py +0 -16
  158. fastmcp/utilities/timeout.py +47 -0
  159. fastmcp/utilities/types.py +1 -1
  160. fastmcp/utilities/versions.py +285 -0
  161. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
  162. fastmcp-3.0.0b1.dist-info/RECORD +228 -0
  163. fastmcp/client/transports.py +0 -1170
  164. fastmcp/contrib/component_manager/component_service.py +0 -209
  165. fastmcp/prompts/prompt_manager.py +0 -117
  166. fastmcp/resources/resource_manager.py +0 -338
  167. fastmcp/server/tasks/converters.py +0 -206
  168. fastmcp/server/tasks/protocol.py +0 -359
  169. fastmcp/tools/tool_manager.py +0 -170
  170. fastmcp/utilities/mcp_config.py +0 -56
  171. fastmcp-2.14.4.dist-info/RECORD +0 -161
  172. /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
  173. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
  174. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
  175. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -18,15 +18,12 @@ production use with enterprise identity providers.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- import base64
22
21
  import hashlib
23
- import hmac
24
- import json
25
22
  import secrets
26
23
  import time
27
24
  from base64 import urlsafe_b64encode
28
- from typing import TYPE_CHECKING, Any, Final
29
- from urllib.parse import urlencode, urlparse
25
+ from typing import Any
26
+ from urllib.parse import urlencode
30
27
 
31
28
  import httpx
32
29
  from authlib.common.security import generate_token
@@ -48,7 +45,7 @@ from mcp.server.auth.settings import (
48
45
  RevocationOptions,
49
46
  )
50
47
  from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
51
- from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, SecretStr
48
+ from pydantic import AnyHttpUrl, AnyUrl, SecretStr
52
49
  from starlette.requests import Request
53
50
  from starlette.responses import HTMLResponse, RedirectResponse
54
51
  from starlette.routing import Route
@@ -61,457 +58,27 @@ from fastmcp.server.auth.jwt_issuer import (
61
58
  JWTIssuer,
62
59
  derive_jwt_key,
63
60
  )
64
- from fastmcp.server.auth.redirect_validation import (
65
- validate_redirect_uri,
61
+ from fastmcp.server.auth.oauth_proxy.consent import ConsentMixin
62
+ from fastmcp.server.auth.oauth_proxy.models import (
63
+ DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
64
+ DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
65
+ DEFAULT_AUTH_CODE_EXPIRY_SECONDS,
66
+ HTTP_TIMEOUT_SECONDS,
67
+ ClientCode,
68
+ JTIMapping,
69
+ OAuthTransaction,
70
+ ProxyDCRClient,
71
+ RefreshTokenMetadata,
72
+ UpstreamTokenSet,
73
+ _hash_token,
66
74
  )
75
+ from fastmcp.server.auth.oauth_proxy.ui import create_error_html
67
76
  from fastmcp.utilities.logging import get_logger
68
- from fastmcp.utilities.ui import (
69
- BUTTON_STYLES,
70
- DETAIL_BOX_STYLES,
71
- DETAILS_STYLES,
72
- INFO_BOX_STYLES,
73
- REDIRECT_SECTION_STYLES,
74
- TOOLTIP_STYLES,
75
- create_logo,
76
- create_page,
77
- create_secure_html_response,
78
- )
79
-
80
- if TYPE_CHECKING:
81
- pass
82
77
 
83
78
  logger = get_logger(__name__)
84
79
 
85
80
 
86
- # -------------------------------------------------------------------------
87
- # Constants
88
- # -------------------------------------------------------------------------
89
-
90
- # Default token expiration times
91
- DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour
92
- DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = (
93
- 60 * 60 * 24 * 365
94
- ) # 1 year
95
- DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
96
-
97
- # HTTP client timeout
98
- HTTP_TIMEOUT_SECONDS: Final[int] = 30
99
-
100
-
101
- # -------------------------------------------------------------------------
102
- # Pydantic Models
103
- # -------------------------------------------------------------------------
104
-
105
-
106
- class OAuthTransaction(BaseModel):
107
- """OAuth transaction state for consent flow.
108
-
109
- Stored server-side to track active authorization flows with client context.
110
- Includes CSRF tokens for consent protection per MCP security best practices.
111
- """
112
-
113
- txn_id: str
114
- client_id: str
115
- client_redirect_uri: str
116
- client_state: str
117
- code_challenge: str | None
118
- code_challenge_method: str
119
- scopes: list[str]
120
- created_at: float
121
- resource: str | None = None
122
- proxy_code_verifier: str | None = None
123
- csrf_token: str | None = None
124
- csrf_expires_at: float | None = None
125
-
126
-
127
- class ClientCode(BaseModel):
128
- """Client authorization code with PKCE and upstream tokens.
129
-
130
- Stored server-side after upstream IdP callback. Contains the upstream
131
- tokens bound to the client's PKCE challenge for secure token exchange.
132
- """
133
-
134
- code: str
135
- client_id: str
136
- redirect_uri: str
137
- code_challenge: str | None
138
- code_challenge_method: str
139
- scopes: list[str]
140
- idp_tokens: dict[str, Any]
141
- expires_at: float
142
- created_at: float
143
-
144
-
145
- class UpstreamTokenSet(BaseModel):
146
- """Stored upstream OAuth tokens from identity provider.
147
-
148
- These tokens are obtained from the upstream provider (Google, GitHub, etc.)
149
- and stored in plaintext within this model. Encryption is handled transparently
150
- at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients.
151
- """
152
-
153
- upstream_token_id: str # Unique ID for this token set
154
- access_token: str # Upstream access token
155
- refresh_token: str | None # Upstream refresh token
156
- refresh_token_expires_at: (
157
- float | None
158
- ) # Unix timestamp when refresh token expires (if known)
159
- expires_at: float # Unix timestamp when access token expires
160
- token_type: str # Usually "Bearer"
161
- scope: str # Space-separated scopes
162
- client_id: str # MCP client this is bound to
163
- created_at: float # Unix timestamp
164
- raw_token_data: dict[str, Any] = Field(default_factory=dict) # Full token response
165
-
166
-
167
- class JTIMapping(BaseModel):
168
- """Maps FastMCP token JTI to upstream token ID.
169
-
170
- This allows stateless JWT validation while still being able to look up
171
- the corresponding upstream token when tools need to access upstream APIs.
172
- """
173
-
174
- jti: str # JWT ID from FastMCP-issued token
175
- upstream_token_id: str # References UpstreamTokenSet
176
- created_at: float # Unix timestamp
177
-
178
-
179
- class RefreshTokenMetadata(BaseModel):
180
- """Metadata for a refresh token, stored keyed by token hash.
181
-
182
- We store only metadata (not the token itself) for security - if storage
183
- is compromised, attackers get hashes they can't reverse into usable tokens.
184
- """
185
-
186
- client_id: str
187
- scopes: list[str]
188
- expires_at: int | None = None
189
- created_at: float
190
-
191
-
192
- def _hash_token(token: str) -> str:
193
- """Hash a token for secure storage lookup.
194
-
195
- Uses SHA-256 to create a one-way hash. The original token cannot be
196
- recovered from the hash, providing defense in depth if storage is compromised.
197
- """
198
- return hashlib.sha256(token.encode()).hexdigest()
199
-
200
-
201
- class ProxyDCRClient(OAuthClientInformationFull):
202
- """Client for DCR proxy with configurable redirect URI validation.
203
-
204
- This special client class is critical for the OAuth proxy to work correctly
205
- with Dynamic Client Registration (DCR). Here's why it exists:
206
-
207
- Problem:
208
- --------
209
- When MCP clients use OAuth, they dynamically register with random localhost
210
- ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to:
211
- 1. Accept these dynamic redirect URIs from clients based on configured patterns
212
- 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.)
213
- 3. Forward the authorization code back to the client's dynamic URI
214
-
215
- Solution:
216
- ---------
217
- This class validates redirect URIs against configurable patterns,
218
- while the proxy internally uses its own fixed redirect URI with the upstream
219
- provider. This allows the flow to work even when clients reconnect with
220
- different ports or when tokens are cached.
221
-
222
- Without proper validation, clients could get "Redirect URI not registered" errors
223
- when trying to authenticate with cached tokens, or security vulnerabilities could
224
- arise from accepting arbitrary redirect URIs.
225
- """
226
-
227
- allowed_redirect_uri_patterns: list[str] | None = Field(default=None)
228
- client_name: str | None = Field(default=None)
229
-
230
- def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
231
- """Validate redirect URI against allowed patterns.
232
-
233
- Since we're acting as a proxy and clients register dynamically,
234
- we validate their redirect URIs against configurable patterns.
235
- This is essential for cached token scenarios where the client may
236
- reconnect with a different port.
237
- """
238
- if redirect_uri is not None:
239
- # Validate against allowed patterns
240
- if validate_redirect_uri(
241
- redirect_uri=redirect_uri,
242
- allowed_patterns=self.allowed_redirect_uri_patterns,
243
- ):
244
- return redirect_uri
245
- # Fall back to normal validation if not in allowed patterns
246
- return super().validate_redirect_uri(redirect_uri)
247
- # If no redirect_uri provided, use default behavior
248
- return super().validate_redirect_uri(redirect_uri)
249
-
250
-
251
- # -------------------------------------------------------------------------
252
- # Helper Functions
253
- # -------------------------------------------------------------------------
254
-
255
-
256
- def create_consent_html(
257
- client_id: str,
258
- redirect_uri: str,
259
- scopes: list[str],
260
- txn_id: str,
261
- csrf_token: str,
262
- client_name: str | None = None,
263
- title: str = "Application Access Request",
264
- server_name: str | None = None,
265
- server_icon_url: str | None = None,
266
- server_website_url: str | None = None,
267
- client_website_url: str | None = None,
268
- csp_policy: str | None = None,
269
- ) -> str:
270
- """Create a styled HTML consent page for OAuth authorization requests.
271
-
272
- Args:
273
- csp_policy: Content Security Policy override.
274
- If None, uses the built-in CSP policy with appropriate directives.
275
- If empty string "", disables CSP entirely (no meta tag is rendered).
276
- If a non-empty string, uses that as the CSP policy value.
277
- """
278
- import html as html_module
279
-
280
- client_display = html_module.escape(client_name or client_id)
281
- server_name_escaped = html_module.escape(server_name or "FastMCP")
282
-
283
- # Make server name a hyperlink if website URL is available
284
- if server_website_url:
285
- website_url_escaped = html_module.escape(server_website_url)
286
- server_display = f'<a href="{website_url_escaped}" target="_blank" rel="noopener noreferrer" class="server-name-link">{server_name_escaped}</a>'
287
- else:
288
- server_display = server_name_escaped
289
-
290
- # Build intro box with call-to-action
291
- intro_box = f"""
292
- <div class="info-box">
293
- <p>The application <strong>{client_display}</strong> wants to access the MCP server <strong>{server_display}</strong>. Please ensure you recognize the callback address below.</p>
294
- </div>
295
- """
296
-
297
- # Build redirect URI section (yellow box, centered)
298
- redirect_uri_escaped = html_module.escape(redirect_uri)
299
- redirect_section = f"""
300
- <div class="redirect-section">
301
- <span class="label">Credentials will be sent to:</span>
302
- <div class="value">{redirect_uri_escaped}</div>
303
- </div>
304
- """
305
-
306
- # Build advanced details with collapsible section
307
- detail_rows = [
308
- ("Application Name", html_module.escape(client_name or client_id)),
309
- ("Application Website", html_module.escape(client_website_url or "N/A")),
310
- ("Application ID", client_id),
311
- ("Redirect URI", redirect_uri_escaped),
312
- (
313
- "Requested Scopes",
314
- ", ".join(html_module.escape(s) for s in scopes) if scopes else "None",
315
- ),
316
- ]
317
-
318
- detail_rows_html = "\n".join(
319
- [
320
- f"""
321
- <div class="detail-row">
322
- <div class="detail-label">{label}:</div>
323
- <div class="detail-value">{value}</div>
324
- </div>
325
- """
326
- for label, value in detail_rows
327
- ]
328
- )
329
-
330
- advanced_details = f"""
331
- <details>
332
- <summary>Advanced Details</summary>
333
- <div class="detail-box">
334
- {detail_rows_html}
335
- </div>
336
- </details>
337
- """
338
-
339
- # Build form with buttons
340
- # Use empty action to submit to current URL (/consent or /mcp/consent)
341
- # The POST handler is registered at the same path as GET
342
- form = f"""
343
- <form id="consentForm" method="POST" action="">
344
- <input type="hidden" name="txn_id" value="{txn_id}" />
345
- <input type="hidden" name="csrf_token" value="{csrf_token}" />
346
- <input type="hidden" name="submit" value="true" />
347
- <div class="button-group">
348
- <button type="submit" name="action" value="approve" class="btn-approve">Allow Access</button>
349
- <button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
350
- </div>
351
- </form>
352
- """
353
-
354
- # Build help link with tooltip (identical to current implementation)
355
- help_link = """
356
- <div class="help-link-container">
357
- <span class="help-link">
358
- Why am I seeing this?
359
- <span class="tooltip">
360
- This FastMCP server requires your consent to allow a new client
361
- to connect. This protects you from <a
362
- href="https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem"
363
- target="_blank" class="tooltip-link">confused deputy
364
- attacks</a>, where malicious clients could impersonate you
365
- and steal access.<br><br>
366
- <a
367
- href="https://gofastmcp.com/servers/auth/oauth-proxy#confused-deputy-attacks"
368
- target="_blank" class="tooltip-link">Learn more about
369
- FastMCP security →</a>
370
- </span>
371
- </span>
372
- </div>
373
- """
374
-
375
- # Build the page content
376
- content = f"""
377
- <div class="container">
378
- {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
379
- <h1>Application Access Request</h1>
380
- {intro_box}
381
- {redirect_section}
382
- {advanced_details}
383
- {form}
384
- </div>
385
- {help_link}
386
- """
387
-
388
- # Additional styles needed for this page
389
- additional_styles = (
390
- INFO_BOX_STYLES
391
- + REDIRECT_SECTION_STYLES
392
- + DETAILS_STYLES
393
- + DETAIL_BOX_STYLES
394
- + BUTTON_STYLES
395
- + TOOLTIP_STYLES
396
- )
397
-
398
- # Determine CSP policy to use
399
- # If csp_policy is None, build the default CSP policy
400
- # If csp_policy is empty string, CSP will be disabled entirely in create_page
401
- # If csp_policy is a non-empty string, use it as-is
402
- if csp_policy is None:
403
- # Need to allow form-action for form submission
404
- # Chrome requires explicit scheme declarations in CSP form-action when redirect chains
405
- # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
406
- parsed_redirect = urlparse(redirect_uri)
407
- redirect_scheme = parsed_redirect.scheme.lower()
408
-
409
- # Build form-action directive with standard schemes plus custom protocol if present
410
- form_action_schemes = ["https:", "http:"]
411
- if redirect_scheme and redirect_scheme not in ("http", "https"):
412
- # Custom protocol scheme (e.g., cursor:, vscode:, etc.)
413
- form_action_schemes.append(f"{redirect_scheme}:")
414
-
415
- form_action_directive = " ".join(form_action_schemes)
416
- csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
417
-
418
- return create_page(
419
- content=content,
420
- title=title,
421
- additional_styles=additional_styles,
422
- csp_policy=csp_policy,
423
- )
424
-
425
-
426
- def create_error_html(
427
- error_title: str,
428
- error_message: str,
429
- error_details: dict[str, str] | None = None,
430
- server_name: str | None = None,
431
- server_icon_url: str | None = None,
432
- ) -> str:
433
- """Create a styled HTML error page for OAuth errors.
434
-
435
- Args:
436
- error_title: The error title (e.g., "OAuth Error", "Authorization Failed")
437
- error_message: The main error message to display
438
- error_details: Optional dictionary of error details to show (e.g., `{"Error Code": "invalid_client"}`)
439
- server_name: Optional server name to display
440
- server_icon_url: Optional URL to server icon/logo
441
-
442
- Returns:
443
- Complete HTML page as a string
444
- """
445
- import html as html_module
446
-
447
- error_message_escaped = html_module.escape(error_message)
448
-
449
- # Build error message box
450
- error_box = f"""
451
- <div class="info-box error">
452
- <p>{error_message_escaped}</p>
453
- </div>
454
- """
455
-
456
- # Build error details section if provided
457
- details_section = ""
458
- if error_details:
459
- detail_rows_html = "\n".join(
460
- [
461
- f"""
462
- <div class="detail-row">
463
- <div class="detail-label">{html_module.escape(label)}:</div>
464
- <div class="detail-value">{html_module.escape(value)}</div>
465
- </div>
466
- """
467
- for label, value in error_details.items()
468
- ]
469
- )
470
-
471
- details_section = f"""
472
- <details>
473
- <summary>Error Details</summary>
474
- <div class="detail-box">
475
- {detail_rows_html}
476
- </div>
477
- </details>
478
- """
479
-
480
- # Build the page content
481
- content = f"""
482
- <div class="container">
483
- {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
484
- <h1>{html_module.escape(error_title)}</h1>
485
- {error_box}
486
- {details_section}
487
- </div>
488
- """
489
-
490
- # Additional styles needed for this page
491
- # Override .info-box.error to use normal text color instead of red
492
- additional_styles = (
493
- INFO_BOX_STYLES
494
- + DETAILS_STYLES
495
- + DETAIL_BOX_STYLES
496
- + """
497
- .info-box.error {
498
- color: #111827;
499
- }
500
- """
501
- )
502
-
503
- # Simple CSP policy for error pages (no forms needed)
504
- csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'"
505
-
506
- return create_page(
507
- content=content,
508
- title=error_title,
509
- additional_styles=additional_styles,
510
- csp_policy=csp_policy,
511
- )
512
-
513
-
514
- class OAuthProxy(OAuthProvider):
81
+ class OAuthProxy(OAuthProvider, ConsentMixin):
515
82
  """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs.
516
83
 
517
84
  Purpose
@@ -674,8 +241,8 @@ class OAuthProxy(OAuthProvider):
674
241
  service_documentation_url: Optional service documentation URL
675
242
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
676
243
  Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*").
677
- If None (default), only localhost redirect URIs are allowed.
678
- If empty list, all redirect URIs are allowed (not recommended for production).
244
+ If None (default), all redirect URIs are allowed (for DCR compatibility).
245
+ If empty list, no redirect URIs are allowed.
679
246
  These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app.
680
247
  valid_scopes: List of all the possible valid scopes for a client.
681
248
  These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided.
@@ -1079,7 +646,7 @@ class OAuthProxy(OAuthProvider):
1079
646
  # Store transaction data for IdP callback processing
1080
647
  if client.client_id is None:
1081
648
  raise AuthorizeError(
1082
- error="invalid_client", # type: ignore[arg-type]
649
+ error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type
1083
650
  error_description="Client ID is required",
1084
651
  )
1085
652
  transaction = OAuthTransaction(
@@ -1162,7 +729,7 @@ class OAuthProxy(OAuthProvider):
1162
729
  # Create authorization code object with PKCE challenge
1163
730
  if client.client_id is None:
1164
731
  raise AuthorizeError(
1165
- error="invalid_client", # type: ignore[arg-type]
732
+ error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type
1166
733
  error_description="Client ID is required",
1167
734
  )
1168
735
  return AuthorizationCode(
@@ -1464,7 +1031,7 @@ class OAuthProxy(OAuthProvider):
1464
1031
 
1465
1032
  try:
1466
1033
  logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8])
1467
- token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
1034
+ token_response: dict[str, Any] = await oauth_client.refresh_token(
1468
1035
  url=self._upstream_token_endpoint,
1469
1036
  refresh_token=upstream_token_set.refresh_token,
1470
1037
  scope=" ".join(upstream_scopes) if upstream_scopes else None,
@@ -1890,7 +1457,7 @@ class OAuthProxy(OAuthProvider):
1890
1457
 
1891
1458
  idp_tokens: dict[str, Any] = await oauth_client.fetch_token(
1892
1459
  **token_params
1893
- ) # type: ignore[misc]
1460
+ )
1894
1461
 
1895
1462
  logger.debug(
1896
1463
  f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})"
@@ -1959,324 +1526,3 @@ class OAuthProxy(OAuthProvider):
1959
1526
  error_message="Internal server error during OAuth callback processing. Please try again.",
1960
1527
  )
1961
1528
  return HTMLResponse(content=html_content, status_code=500)
1962
-
1963
- # -------------------------------------------------------------------------
1964
- # Consent Interstitial
1965
- # -------------------------------------------------------------------------
1966
-
1967
- def _normalize_uri(self, uri: str) -> str:
1968
- """Normalize a URI to a canonical form for consent tracking."""
1969
- parsed = urlparse(uri)
1970
- path = parsed.path or ""
1971
- normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}"
1972
- if normalized.endswith("/") and len(path) > 1:
1973
- normalized = normalized[:-1]
1974
- return normalized
1975
-
1976
- def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str:
1977
- """Create a stable key for consent tracking from client_id and redirect_uri."""
1978
- normalized = self._normalize_uri(str(redirect_uri))
1979
- return f"{client_id}:{normalized}"
1980
-
1981
- def _cookie_name(self, base_name: str) -> str:
1982
- """Return secure cookie name for HTTPS, fallback for HTTP development."""
1983
- if self._is_https:
1984
- return f"__Host-{base_name}"
1985
- return f"__{base_name}"
1986
-
1987
- def _sign_cookie(self, payload: str) -> str:
1988
- """Sign a cookie payload with HMAC-SHA256.
1989
-
1990
- Returns: base64(payload).base64(signature)
1991
- """
1992
- # Use upstream client secret as signing key
1993
- key = self._upstream_client_secret.get_secret_value().encode()
1994
- signature = hmac.new(key, payload.encode(), hashlib.sha256).digest()
1995
- signature_b64 = base64.b64encode(signature).decode()
1996
- return f"{payload}.{signature_b64}"
1997
-
1998
- def _verify_cookie(self, signed_value: str) -> str | None:
1999
- """Verify and extract payload from signed cookie.
2000
-
2001
- Returns: payload if signature valid, None otherwise
2002
- """
2003
- try:
2004
- if "." not in signed_value:
2005
- return None
2006
- payload, signature_b64 = signed_value.rsplit(".", 1)
2007
-
2008
- # Verify signature
2009
- key = self._upstream_client_secret.get_secret_value().encode()
2010
- expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest()
2011
- provided_sig = base64.b64decode(signature_b64.encode())
2012
-
2013
- # Constant-time comparison
2014
- if not hmac.compare_digest(expected_sig, provided_sig):
2015
- return None
2016
-
2017
- return payload
2018
- except Exception:
2019
- return None
2020
-
2021
- def _decode_list_cookie(self, request: Request, base_name: str) -> list[str]:
2022
- """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid."""
2023
- # Prefer secure name, but also check non-secure variant for dev
2024
- secure_name = self._cookie_name(base_name)
2025
- raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}")
2026
- if not raw:
2027
- return []
2028
- try:
2029
- # Verify signature
2030
- payload = self._verify_cookie(raw)
2031
- if not payload:
2032
- logger.debug("Cookie signature verification failed for %s", secure_name)
2033
- return []
2034
-
2035
- # Decode payload
2036
- data = base64.b64decode(payload.encode())
2037
- value = json.loads(data.decode())
2038
- if isinstance(value, list):
2039
- return [str(x) for x in value]
2040
- except Exception:
2041
- logger.debug("Failed to decode cookie %s; treating as empty", secure_name)
2042
- return []
2043
-
2044
- def _encode_list_cookie(self, values: list[str]) -> str:
2045
- """Encode values to base64 and sign with HMAC.
2046
-
2047
- Returns: signed cookie value (payload.signature)
2048
- """
2049
- payload = json.dumps(values, separators=(",", ":")).encode()
2050
- payload_b64 = base64.b64encode(payload).decode()
2051
- return self._sign_cookie(payload_b64)
2052
-
2053
- def _set_list_cookie(
2054
- self,
2055
- response: HTMLResponse | RedirectResponse,
2056
- base_name: str,
2057
- value_b64: str,
2058
- max_age: int,
2059
- ) -> None:
2060
- name = self._cookie_name(base_name)
2061
- response.set_cookie(
2062
- name,
2063
- value_b64,
2064
- max_age=max_age,
2065
- secure=self._is_https,
2066
- httponly=True,
2067
- samesite="lax",
2068
- path="/",
2069
- )
2070
-
2071
- def _build_upstream_authorize_url(
2072
- self, txn_id: str, transaction: dict[str, Any]
2073
- ) -> str:
2074
- """Construct the upstream IdP authorization URL using stored transaction data."""
2075
- query_params: dict[str, Any] = {
2076
- "response_type": "code",
2077
- "client_id": self._upstream_client_id,
2078
- "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}",
2079
- "state": txn_id,
2080
- }
2081
-
2082
- scopes_to_use = transaction.get("scopes") or self.required_scopes or []
2083
- if scopes_to_use:
2084
- query_params["scope"] = " ".join(scopes_to_use)
2085
-
2086
- # If PKCE forwarding was enabled, include the proxy challenge
2087
- proxy_code_verifier = transaction.get("proxy_code_verifier")
2088
- if proxy_code_verifier:
2089
- challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest()
2090
- proxy_code_challenge = (
2091
- urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
2092
- )
2093
- query_params["code_challenge"] = proxy_code_challenge
2094
- query_params["code_challenge_method"] = "S256"
2095
-
2096
- # Forward resource indicator if present in transaction
2097
- if resource := transaction.get("resource"):
2098
- query_params["resource"] = resource
2099
-
2100
- # Extra configured parameters
2101
- if self._extra_authorize_params:
2102
- query_params.update(self._extra_authorize_params)
2103
-
2104
- separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
2105
- return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
2106
-
2107
- async def _handle_consent(
2108
- self, request: Request
2109
- ) -> HTMLResponse | RedirectResponse:
2110
- """Handle consent page - dispatch to GET or POST handler based on method."""
2111
- if request.method == "POST":
2112
- return await self._submit_consent(request)
2113
- return await self._show_consent_page(request)
2114
-
2115
- async def _show_consent_page(
2116
- self, request: Request
2117
- ) -> HTMLResponse | RedirectResponse:
2118
- """Display consent page or auto-approve/deny based on cookies."""
2119
- from fastmcp.server.server import FastMCP
2120
-
2121
- txn_id = request.query_params.get("txn_id")
2122
- if not txn_id:
2123
- return create_secure_html_response(
2124
- "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
2125
- )
2126
-
2127
- txn_model = await self._transaction_store.get(key=txn_id)
2128
- if not txn_model:
2129
- return create_secure_html_response(
2130
- "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
2131
- )
2132
-
2133
- txn = txn_model.model_dump()
2134
- client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"])
2135
-
2136
- approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS"))
2137
- denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS"))
2138
-
2139
- if client_key in approved:
2140
- upstream_url = self._build_upstream_authorize_url(txn_id, txn)
2141
- return RedirectResponse(url=upstream_url, status_code=302)
2142
-
2143
- if client_key in denied:
2144
- callback_params = {
2145
- "error": "access_denied",
2146
- "state": txn.get("client_state") or "",
2147
- }
2148
- sep = "&" if "?" in txn["client_redirect_uri"] else "?"
2149
- return RedirectResponse(
2150
- url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}",
2151
- status_code=302,
2152
- )
2153
-
2154
- # Need consent: issue CSRF token and show HTML
2155
- csrf_token = secrets.token_urlsafe(32)
2156
- csrf_expires_at = time.time() + 15 * 60
2157
-
2158
- # Update transaction with CSRF token
2159
- txn_model.csrf_token = csrf_token
2160
- txn_model.csrf_expires_at = csrf_expires_at
2161
- await self._transaction_store.put(
2162
- key=txn_id, value=txn_model, ttl=15 * 60
2163
- ) # Auto-expire after 15 minutes
2164
-
2165
- # Update dict for use in HTML generation
2166
- txn["csrf_token"] = csrf_token
2167
- txn["csrf_expires_at"] = csrf_expires_at
2168
-
2169
- # Load client to get client_name if available
2170
- client = await self.get_client(txn["client_id"])
2171
- client_name = getattr(client, "client_name", None) if client else None
2172
-
2173
- # Extract server metadata from app state
2174
- fastmcp = getattr(request.app.state, "fastmcp_server", None)
2175
-
2176
- if isinstance(fastmcp, FastMCP):
2177
- server_name = fastmcp.name
2178
- icons = fastmcp.icons
2179
- server_icon_url = icons[0].src if icons else None
2180
- server_website_url = fastmcp.website_url
2181
- else:
2182
- server_name = None
2183
- server_icon_url = None
2184
- server_website_url = None
2185
-
2186
- html = create_consent_html(
2187
- client_id=txn["client_id"],
2188
- redirect_uri=txn["client_redirect_uri"],
2189
- scopes=txn.get("scopes") or [],
2190
- txn_id=txn_id,
2191
- csrf_token=csrf_token,
2192
- client_name=client_name,
2193
- server_name=server_name,
2194
- server_icon_url=server_icon_url,
2195
- server_website_url=server_website_url,
2196
- csp_policy=self._consent_csp_policy,
2197
- )
2198
- response = create_secure_html_response(html)
2199
- # Store CSRF in cookie with short lifetime
2200
- self._set_list_cookie(
2201
- response,
2202
- "MCP_CONSENT_STATE",
2203
- self._encode_list_cookie([csrf_token]),
2204
- max_age=15 * 60,
2205
- )
2206
- return response
2207
-
2208
- async def _submit_consent(
2209
- self, request: Request
2210
- ) -> RedirectResponse | HTMLResponse:
2211
- """Handle consent approval/denial, set cookies, and redirect appropriately."""
2212
- form = await request.form()
2213
- txn_id = str(form.get("txn_id", ""))
2214
- action = str(form.get("action", ""))
2215
- csrf_token = str(form.get("csrf_token", ""))
2216
-
2217
- if not txn_id:
2218
- return create_secure_html_response(
2219
- "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
2220
- )
2221
-
2222
- txn_model = await self._transaction_store.get(key=txn_id)
2223
- if not txn_model:
2224
- return create_secure_html_response(
2225
- "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
2226
- )
2227
-
2228
- txn = txn_model.model_dump()
2229
- expected_csrf = txn.get("csrf_token")
2230
- expires_at = float(txn.get("csrf_expires_at") or 0)
2231
-
2232
- if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at:
2233
- return create_secure_html_response(
2234
- "<h1>Error</h1><p>Invalid or expired consent token</p>", status_code=400
2235
- )
2236
-
2237
- client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"])
2238
-
2239
- if action == "approve":
2240
- approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS"))
2241
- if client_key not in approved:
2242
- approved.add(client_key)
2243
- approved_b64 = self._encode_list_cookie(sorted(approved))
2244
-
2245
- upstream_url = self._build_upstream_authorize_url(txn_id, txn)
2246
- response = RedirectResponse(url=upstream_url, status_code=302)
2247
- self._set_list_cookie(
2248
- response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600
2249
- )
2250
- # Clear CSRF cookie by setting empty short-lived value
2251
- self._set_list_cookie(
2252
- response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
2253
- )
2254
- return response
2255
-
2256
- elif action == "deny":
2257
- denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS"))
2258
- if client_key not in denied:
2259
- denied.add(client_key)
2260
- denied_b64 = self._encode_list_cookie(sorted(denied))
2261
-
2262
- callback_params = {
2263
- "error": "access_denied",
2264
- "state": txn.get("client_state") or "",
2265
- }
2266
- sep = "&" if "?" in txn["client_redirect_uri"] else "?"
2267
- client_callback_url = (
2268
- f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}"
2269
- )
2270
- response = RedirectResponse(url=client_callback_url, status_code=302)
2271
- self._set_list_cookie(
2272
- response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600
2273
- )
2274
- self._set_list_cookie(
2275
- response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
2276
- )
2277
- return response
2278
-
2279
- else:
2280
- return create_secure_html_response(
2281
- "<h1>Error</h1><p>Invalid action</p>", status_code=400
2282
- )