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,326 @@
1
+ """Enhanced authorization handler with improved error responses.
2
+
3
+ This module provides an enhanced authorization handler that wraps the MCP SDK's
4
+ AuthorizationHandler to provide better error messages when clients attempt to
5
+ authorize with unregistered client IDs.
6
+
7
+ The enhancement adds:
8
+ - Content negotiation: HTML for browsers, JSON for API clients
9
+ - Enhanced JSON responses with registration endpoint hints
10
+ - Styled HTML error pages with registration links/forms
11
+ - Link headers pointing to registration endpoints
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import TYPE_CHECKING
18
+
19
+ from mcp.server.auth.handlers.authorize import (
20
+ AuthorizationHandler as SDKAuthorizationHandler,
21
+ )
22
+ from pydantic import AnyHttpUrl
23
+ from starlette.requests import Request
24
+ from starlette.responses import Response
25
+
26
+ from fastmcp.utilities.logging import get_logger
27
+ from fastmcp.utilities.ui import (
28
+ INFO_BOX_STYLES,
29
+ TOOLTIP_STYLES,
30
+ create_logo,
31
+ create_page,
32
+ create_secure_html_response,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from mcp.server.auth.provider import OAuthAuthorizationServerProvider
37
+
38
+ logger = get_logger(__name__)
39
+
40
+
41
+ def create_unregistered_client_html(
42
+ client_id: str,
43
+ registration_endpoint: str,
44
+ discovery_endpoint: str,
45
+ server_name: str | None = None,
46
+ server_icon_url: str | None = None,
47
+ title: str = "Client Not Registered",
48
+ ) -> str:
49
+ """Create styled HTML error page for unregistered client attempts.
50
+
51
+ Args:
52
+ client_id: The unregistered client ID that was provided
53
+ registration_endpoint: URL of the registration endpoint
54
+ discovery_endpoint: URL of the OAuth metadata discovery endpoint
55
+ server_name: Optional server name for branding
56
+ server_icon_url: Optional server icon URL
57
+ title: Page title
58
+
59
+ Returns:
60
+ HTML string for the error page
61
+ """
62
+ import html as html_module
63
+
64
+ client_id_escaped = html_module.escape(client_id)
65
+
66
+ # Main error message
67
+ error_box = f"""
68
+ <div class="info-box error">
69
+ <p>The client ID <code>{client_id_escaped}</code> was not found in the server's client registry.</p>
70
+ </div>
71
+ """
72
+
73
+ # What to do - yellow warning box
74
+ warning_box = """
75
+ <div class="info-box warning">
76
+ <p>Your MCP client opened this page to complete OAuth authorization,
77
+ but the server did not recognize its client ID. To fix this:</p>
78
+ <ul>
79
+ <li>Close this browser window</li>
80
+ <li>Clear authentication tokens in your MCP client (or restart it)</li>
81
+ <li>Try connecting again - your client should automatically re-register</li>
82
+ </ul>
83
+ </div>
84
+ """
85
+
86
+ # Help link with tooltip (similar to consent screen)
87
+ help_link = """
88
+ <div class="help-link-container">
89
+ <span class="help-link">
90
+ Why am I seeing this?
91
+ <span class="tooltip">
92
+ OAuth 2.0 requires clients to register before authorization.
93
+ This server returned a 400 error because the provided client
94
+ ID was not found.
95
+ <br><br>
96
+ In browser-delegated OAuth flows, your application cannot
97
+ detect this error automatically; it's waiting for a
98
+ callback that will never arrive. You must manually clear
99
+ auth tokens and reconnect.
100
+ </span>
101
+ </span>
102
+ </div>
103
+ """
104
+
105
+ # Build page content
106
+ content = f"""
107
+ <div class="container">
108
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
109
+ <h1>{title}</h1>
110
+ {error_box}
111
+ {warning_box}
112
+ </div>
113
+ {help_link}
114
+ """
115
+
116
+ # Use same styles as consent page
117
+ additional_styles = (
118
+ INFO_BOX_STYLES
119
+ + TOOLTIP_STYLES
120
+ + """
121
+ /* Error variant for info-box */
122
+ .info-box.error {
123
+ background: #fef2f2;
124
+ border-color: #f87171;
125
+ }
126
+ .info-box.error strong {
127
+ color: #991b1b;
128
+ }
129
+ /* Warning variant for info-box (yellow) */
130
+ .info-box.warning {
131
+ background: #fffbeb;
132
+ border-color: #fbbf24;
133
+ }
134
+ .info-box.warning strong {
135
+ color: #92400e;
136
+ }
137
+ .info-box code {
138
+ background: rgba(0, 0, 0, 0.05);
139
+ padding: 2px 6px;
140
+ border-radius: 3px;
141
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
142
+ font-size: 0.9em;
143
+ }
144
+ .info-box ul {
145
+ margin: 10px 0;
146
+ padding-left: 20px;
147
+ }
148
+ .info-box li {
149
+ margin: 6px 0;
150
+ }
151
+ """
152
+ )
153
+
154
+ return create_page(
155
+ content=content,
156
+ title=title,
157
+ additional_styles=additional_styles,
158
+ )
159
+
160
+
161
+ class AuthorizationHandler(SDKAuthorizationHandler):
162
+ """Authorization handler with enhanced error responses for unregistered clients.
163
+
164
+ This handler extends the MCP SDK's AuthorizationHandler to provide better UX
165
+ when clients attempt to authorize without being registered. It implements
166
+ content negotiation to return:
167
+
168
+ - HTML error pages for browser requests
169
+ - Enhanced JSON with registration hints for API clients
170
+ - Link headers pointing to registration endpoints
171
+
172
+ This maintains OAuth 2.1 compliance (returns 400 for invalid client_id)
173
+ while providing actionable guidance to fix the error.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ provider: OAuthAuthorizationServerProvider,
179
+ base_url: AnyHttpUrl | str,
180
+ server_name: str | None = None,
181
+ server_icon_url: str | None = None,
182
+ ):
183
+ """Initialize the enhanced authorization handler.
184
+
185
+ Args:
186
+ provider: OAuth authorization server provider
187
+ base_url: Base URL of the server for constructing endpoint URLs
188
+ server_name: Optional server name for branding
189
+ server_icon_url: Optional server icon URL for branding
190
+ """
191
+ super().__init__(provider)
192
+ self._base_url = str(base_url).rstrip("/")
193
+ self._server_name = server_name
194
+ self._server_icon_url = server_icon_url
195
+
196
+ async def handle(self, request: Request) -> Response:
197
+ """Handle authorization request with enhanced error responses.
198
+
199
+ This method extends the SDK's authorization handler and intercepts
200
+ errors for unregistered clients to provide better error responses
201
+ based on the client's Accept header.
202
+
203
+ Args:
204
+ request: The authorization request
205
+
206
+ Returns:
207
+ Response (redirect on success, error response on failure)
208
+ """
209
+ # Call the SDK handler
210
+ response = await super().handle(request)
211
+
212
+ # Check if this is a client not found error
213
+ if response.status_code == 400:
214
+ # Try to extract client_id from request for enhanced error
215
+ client_id: str | None = None
216
+ if request.method == "GET":
217
+ client_id = request.query_params.get("client_id")
218
+ else:
219
+ form = await request.form()
220
+ client_id_value = form.get("client_id")
221
+ # Ensure client_id is a string, not UploadFile
222
+ if isinstance(client_id_value, str):
223
+ client_id = client_id_value
224
+
225
+ # If we have a client_id and the error is about it not being found,
226
+ # enhance the response
227
+ if client_id:
228
+ try:
229
+ # Check if response body contains "not found" error
230
+ if hasattr(response, "body"):
231
+ body = json.loads(bytes(response.body))
232
+ if (
233
+ body.get("error") == "invalid_request"
234
+ and "not found" in body.get("error_description", "").lower()
235
+ ):
236
+ return await self._create_enhanced_error_response(
237
+ request, client_id, body.get("state")
238
+ )
239
+ except Exception:
240
+ # If we can't parse the response, just return the original
241
+ pass
242
+
243
+ return response
244
+
245
+ async def _create_enhanced_error_response(
246
+ self, request: Request, client_id: str, state: str | None
247
+ ) -> Response:
248
+ """Create enhanced error response with content negotiation.
249
+
250
+ Args:
251
+ request: The original request
252
+ client_id: The unregistered client ID
253
+ state: The state parameter from the request
254
+
255
+ Returns:
256
+ HTML or JSON error response based on Accept header
257
+ """
258
+ registration_endpoint = f"{self._base_url}/register"
259
+ discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server"
260
+
261
+ # Extract server metadata from app state (same pattern as consent screen)
262
+ from fastmcp.server.server import FastMCP
263
+
264
+ fastmcp = getattr(request.app.state, "fastmcp_server", None)
265
+
266
+ if isinstance(fastmcp, FastMCP):
267
+ server_name = fastmcp.name
268
+ icons = fastmcp.icons
269
+ server_icon_url = icons[0].src if icons else None
270
+ else:
271
+ server_name = self._server_name
272
+ server_icon_url = self._server_icon_url
273
+
274
+ # Check Accept header for content negotiation
275
+ accept = request.headers.get("accept", "")
276
+
277
+ # Prefer HTML for browsers
278
+ if "text/html" in accept:
279
+ html = create_unregistered_client_html(
280
+ client_id=client_id,
281
+ registration_endpoint=registration_endpoint,
282
+ discovery_endpoint=discovery_endpoint,
283
+ server_name=server_name,
284
+ server_icon_url=server_icon_url,
285
+ )
286
+ response = create_secure_html_response(html, status_code=400)
287
+ else:
288
+ # Return enhanced JSON for API clients
289
+ from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse
290
+
291
+ error_data = AuthorizationErrorResponse(
292
+ error="invalid_request",
293
+ error_description=(
294
+ f"Client ID '{client_id}' is not registered with this server. "
295
+ f"MCP clients should automatically re-register by sending a POST request to "
296
+ f"the registration_endpoint and retry authorization. "
297
+ f"If this persists, clear cached authentication tokens and reconnect."
298
+ ),
299
+ state=state,
300
+ )
301
+
302
+ # Add extra fields to help clients discover registration
303
+ error_dict = error_data.model_dump(exclude_none=True)
304
+ error_dict["registration_endpoint"] = registration_endpoint
305
+ error_dict["authorization_server_metadata"] = discovery_endpoint
306
+
307
+ from starlette.responses import JSONResponse
308
+
309
+ response = JSONResponse(
310
+ status_code=400,
311
+ content=error_dict,
312
+ headers={"Cache-Control": "no-store"},
313
+ )
314
+
315
+ # Add Link header for registration endpoint discovery
316
+ response.headers["Link"] = (
317
+ f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"'
318
+ )
319
+
320
+ logger.info(
321
+ "Unregistered client_id=%s, returned %s error response",
322
+ client_id,
323
+ "HTML" if "text/html" in accept else "JSON",
324
+ )
325
+
326
+ return response
@@ -0,0 +1,236 @@
1
+ """JWT token issuance and verification for FastMCP OAuth Proxy.
2
+
3
+ This module implements the token factory pattern for OAuth proxies, where the proxy
4
+ issues its own JWT tokens to clients instead of forwarding upstream provider tokens.
5
+ This maintains proper OAuth 2.0 token audience boundaries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import time
12
+ from typing import Any, overload
13
+
14
+ from authlib.jose import JsonWebToken
15
+ from authlib.jose.errors import JoseError
16
+ from cryptography.hazmat.primitives import hashes
17
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
+
20
+ from fastmcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ KDF_ITERATIONS = 1000000
25
+
26
+
27
+ @overload
28
+ def derive_jwt_key(*, high_entropy_material: str, salt: str) -> bytes:
29
+ """Derive JWT signing key from a high-entropy key material and server salt."""
30
+
31
+
32
+ @overload
33
+ def derive_jwt_key(*, low_entropy_material: str, salt: str) -> bytes:
34
+ """Derive JWT signing key from a low-entropy key material and server salt."""
35
+
36
+
37
+ def derive_jwt_key(
38
+ *,
39
+ high_entropy_material: str | None = None,
40
+ low_entropy_material: str | None = None,
41
+ salt: str,
42
+ ) -> bytes:
43
+ """Derive JWT signing key from a high-entropy or low-entropy key material and server salt."""
44
+ if high_entropy_material is not None and low_entropy_material is not None:
45
+ raise ValueError(
46
+ "Either high_entropy_material or low_entropy_material must be provided, but not both"
47
+ )
48
+
49
+ if high_entropy_material is not None:
50
+ derived_key = HKDF(
51
+ algorithm=hashes.SHA256(),
52
+ length=32,
53
+ salt=salt.encode(),
54
+ info=b"Fernet",
55
+ ).derive(key_material=high_entropy_material.encode())
56
+
57
+ return base64.urlsafe_b64encode(derived_key)
58
+
59
+ if low_entropy_material is not None:
60
+ pbkdf2 = PBKDF2HMAC(
61
+ algorithm=hashes.SHA256(),
62
+ length=32,
63
+ salt=salt.encode(),
64
+ iterations=KDF_ITERATIONS,
65
+ ).derive(key_material=low_entropy_material.encode())
66
+
67
+ return base64.urlsafe_b64encode(pbkdf2)
68
+
69
+ raise ValueError(
70
+ "Either high_entropy_material or low_entropy_material must be provided"
71
+ )
72
+
73
+
74
+ class JWTIssuer:
75
+ """Issues and validates FastMCP-signed JWT tokens using HS256.
76
+
77
+ This issuer creates JWT tokens for MCP clients with proper audience claims,
78
+ maintaining OAuth 2.0 token boundaries. Tokens are signed with HS256 using
79
+ a key derived from the upstream client secret.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ issuer: str,
85
+ audience: str,
86
+ signing_key: bytes,
87
+ ):
88
+ """Initialize JWT issuer.
89
+
90
+ Args:
91
+ issuer: Token issuer (FastMCP server base URL)
92
+ audience: Token audience (typically {base_url}/mcp)
93
+ signing_key: HS256 signing key (32 bytes)
94
+ """
95
+ self.issuer = issuer
96
+ self.audience = audience
97
+ self._signing_key = signing_key
98
+ self._jwt = JsonWebToken(["HS256"])
99
+
100
+ def issue_access_token(
101
+ self,
102
+ client_id: str,
103
+ scopes: list[str],
104
+ jti: str,
105
+ expires_in: int = 3600,
106
+ ) -> str:
107
+ """Issue a minimal FastMCP access token.
108
+
109
+ FastMCP tokens are reference tokens containing only the minimal claims
110
+ needed for validation and lookup. The JTI maps to the upstream token
111
+ which contains actual user identity and authorization data.
112
+
113
+ Args:
114
+ client_id: MCP client ID
115
+ scopes: Token scopes
116
+ jti: Unique token identifier (maps to upstream token)
117
+ expires_in: Token lifetime in seconds
118
+
119
+ Returns:
120
+ Signed JWT token
121
+ """
122
+ now = int(time.time())
123
+
124
+ header = {"alg": "HS256", "typ": "JWT"}
125
+ payload = {
126
+ "iss": self.issuer,
127
+ "aud": self.audience,
128
+ "client_id": client_id,
129
+ "scope": " ".join(scopes),
130
+ "exp": now + expires_in,
131
+ "iat": now,
132
+ "jti": jti,
133
+ }
134
+
135
+ token_bytes = self._jwt.encode(header, payload, self._signing_key)
136
+ token = token_bytes.decode("utf-8")
137
+
138
+ logger.debug(
139
+ "Issued access token for client=%s jti=%s exp=%d",
140
+ client_id,
141
+ jti[:8],
142
+ payload["exp"],
143
+ )
144
+
145
+ return token
146
+
147
+ def issue_refresh_token(
148
+ self,
149
+ client_id: str,
150
+ scopes: list[str],
151
+ jti: str,
152
+ expires_in: int,
153
+ ) -> str:
154
+ """Issue a minimal FastMCP refresh token.
155
+
156
+ FastMCP refresh tokens are reference tokens containing only the minimal
157
+ claims needed for validation and lookup. The JTI maps to the upstream
158
+ token which contains actual user identity and authorization data.
159
+
160
+ Args:
161
+ client_id: MCP client ID
162
+ scopes: Token scopes
163
+ jti: Unique token identifier (maps to upstream token)
164
+ expires_in: Token lifetime in seconds (should match upstream refresh expiry)
165
+
166
+ Returns:
167
+ Signed JWT token
168
+ """
169
+ now = int(time.time())
170
+
171
+ header = {"alg": "HS256", "typ": "JWT"}
172
+ payload = {
173
+ "iss": self.issuer,
174
+ "aud": self.audience,
175
+ "client_id": client_id,
176
+ "scope": " ".join(scopes),
177
+ "exp": now + expires_in,
178
+ "iat": now,
179
+ "jti": jti,
180
+ "token_use": "refresh",
181
+ }
182
+
183
+ token_bytes = self._jwt.encode(header, payload, self._signing_key)
184
+ token = token_bytes.decode("utf-8")
185
+
186
+ logger.debug(
187
+ "Issued refresh token for client=%s jti=%s exp=%d",
188
+ client_id,
189
+ jti[:8],
190
+ payload["exp"],
191
+ )
192
+
193
+ return token
194
+
195
+ def verify_token(self, token: str) -> dict[str, Any]:
196
+ """Verify and decode a FastMCP token.
197
+
198
+ Validates JWT signature, expiration, issuer, and audience.
199
+
200
+ Args:
201
+ token: JWT token to verify
202
+
203
+ Returns:
204
+ Decoded token payload
205
+
206
+ Raises:
207
+ JoseError: If token is invalid, expired, or has wrong claims
208
+ """
209
+ try:
210
+ # Decode and verify signature
211
+ payload = self._jwt.decode(token, self._signing_key)
212
+
213
+ # Validate expiration
214
+ exp = payload.get("exp")
215
+ if exp and exp < time.time():
216
+ logger.debug("Token expired")
217
+ raise JoseError("Token has expired")
218
+
219
+ # Validate issuer
220
+ if payload.get("iss") != self.issuer:
221
+ logger.debug("Token has invalid issuer")
222
+ raise JoseError("Invalid token issuer")
223
+
224
+ # Validate audience
225
+ if payload.get("aud") != self.audience:
226
+ logger.debug("Token has invalid audience")
227
+ raise JoseError("Invalid token audience")
228
+
229
+ logger.debug(
230
+ "Token verified successfully for subject=%s", payload.get("sub")
231
+ )
232
+ return payload
233
+
234
+ except JoseError as e:
235
+ logger.debug("Token validation failed: %s", e)
236
+ raise
@@ -0,0 +1,96 @@
1
+ """Enhanced authentication middleware with better error messages.
2
+
3
+ This module provides enhanced versions of MCP SDK authentication middleware
4
+ that return more helpful error messages for developers troubleshooting
5
+ authentication issues.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from mcp.server.auth.middleware.bearer_auth import (
13
+ RequireAuthMiddleware as SDKRequireAuthMiddleware,
14
+ )
15
+ from starlette.types import Send
16
+
17
+ from fastmcp.utilities.logging import get_logger
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class RequireAuthMiddleware(SDKRequireAuthMiddleware):
23
+ """Enhanced authentication middleware with detailed error messages.
24
+
25
+ Extends the SDK's RequireAuthMiddleware to provide more actionable
26
+ error messages when authentication fails. This helps developers
27
+ understand what went wrong and how to fix it.
28
+ """
29
+
30
+ async def _send_auth_error(
31
+ self, send: Send, status_code: int, error: str, description: str
32
+ ) -> None:
33
+ """Send an authentication error response with enhanced error messages.
34
+
35
+ Overrides the SDK's _send_auth_error to provide more detailed
36
+ error descriptions that help developers troubleshoot authentication
37
+ issues.
38
+
39
+ Args:
40
+ send: ASGI send callable
41
+ status_code: HTTP status code (401 or 403)
42
+ error: OAuth error code
43
+ description: Base error description
44
+ """
45
+ # Enhance error descriptions based on error type
46
+ enhanced_description = description
47
+
48
+ if error == "invalid_token" and status_code == 401:
49
+ # This is the "Authentication required" error
50
+ enhanced_description = (
51
+ "Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. "
52
+ "To resolve: clear authentication tokens in your MCP client and reconnect. "
53
+ "Your client should automatically re-register and obtain new tokens."
54
+ )
55
+ elif error == "insufficient_scope":
56
+ # Scope error - already has good detail from SDK
57
+ pass
58
+
59
+ # Build WWW-Authenticate header value
60
+ www_auth_parts = [
61
+ f'error="{error}"',
62
+ f'error_description="{enhanced_description}"',
63
+ ]
64
+ if self.resource_metadata_url:
65
+ www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
66
+
67
+ www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
68
+
69
+ # Send response
70
+ body = {"error": error, "error_description": enhanced_description}
71
+ body_bytes = json.dumps(body).encode()
72
+
73
+ await send(
74
+ {
75
+ "type": "http.response.start",
76
+ "status": status_code,
77
+ "headers": [
78
+ (b"content-type", b"application/json"),
79
+ (b"content-length", str(len(body_bytes)).encode()),
80
+ (b"www-authenticate", www_authenticate.encode()),
81
+ ],
82
+ }
83
+ )
84
+
85
+ await send(
86
+ {
87
+ "type": "http.response.body",
88
+ "body": body_bytes,
89
+ }
90
+ )
91
+
92
+ logger.info(
93
+ "Auth error returned: %s (status=%d)",
94
+ error,
95
+ status_code,
96
+ )