fastmcp 2.13.0.1__py3-none-any.whl → 2.13.1__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 (42) hide show
  1. fastmcp/cli/cli.py +3 -4
  2. fastmcp/cli/install/cursor.py +12 -6
  3. fastmcp/client/auth/oauth.py +11 -6
  4. fastmcp/client/client.py +86 -20
  5. fastmcp/client/transports.py +4 -4
  6. fastmcp/experimental/utilities/openapi/director.py +13 -14
  7. fastmcp/experimental/utilities/openapi/parser.py +18 -15
  8. fastmcp/mcp_config.py +1 -1
  9. fastmcp/resources/resource_manager.py +3 -3
  10. fastmcp/server/auth/__init__.py +4 -0
  11. fastmcp/server/auth/auth.py +28 -9
  12. fastmcp/server/auth/handlers/authorize.py +7 -5
  13. fastmcp/server/auth/oauth_proxy.py +170 -30
  14. fastmcp/server/auth/oidc_proxy.py +28 -9
  15. fastmcp/server/auth/providers/azure.py +26 -5
  16. fastmcp/server/auth/providers/debug.py +114 -0
  17. fastmcp/server/auth/providers/descope.py +1 -1
  18. fastmcp/server/auth/providers/in_memory.py +25 -1
  19. fastmcp/server/auth/providers/jwt.py +38 -26
  20. fastmcp/server/auth/providers/oci.py +233 -0
  21. fastmcp/server/auth/providers/supabase.py +21 -5
  22. fastmcp/server/auth/providers/workos.py +1 -1
  23. fastmcp/server/context.py +50 -8
  24. fastmcp/server/dependencies.py +8 -2
  25. fastmcp/server/middleware/caching.py +9 -2
  26. fastmcp/server/middleware/logging.py +2 -2
  27. fastmcp/server/middleware/middleware.py +2 -2
  28. fastmcp/server/proxy.py +1 -1
  29. fastmcp/server/server.py +11 -5
  30. fastmcp/tools/tool.py +33 -8
  31. fastmcp/utilities/components.py +2 -2
  32. fastmcp/utilities/json_schema.py +4 -4
  33. fastmcp/utilities/logging.py +13 -9
  34. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  35. fastmcp/utilities/openapi.py +2 -2
  36. fastmcp/utilities/types.py +28 -15
  37. fastmcp/utilities/ui.py +1 -1
  38. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/METADATA +14 -11
  39. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/RECORD +42 -40
  40. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/WHEEL +0 -0
  41. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/entry_points.txt +0 -0
  42. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/licenses/LICENSE +0 -0
@@ -13,6 +13,7 @@ The enhancement adds:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import json
16
17
  from typing import TYPE_CHECKING
17
18
 
18
19
  from mcp.server.auth.handlers.authorize import (
@@ -211,12 +212,15 @@ class AuthorizationHandler(SDKAuthorizationHandler):
211
212
  # Check if this is a client not found error
212
213
  if response.status_code == 400:
213
214
  # Try to extract client_id from request for enhanced error
214
- client_id = None
215
+ client_id: str | None = None
215
216
  if request.method == "GET":
216
217
  client_id = request.query_params.get("client_id")
217
218
  else:
218
219
  form = await request.form()
219
- client_id = form.get("client_id")
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
220
224
 
221
225
  # If we have a client_id and the error is about it not being found,
222
226
  # enhance the response
@@ -224,9 +228,7 @@ class AuthorizationHandler(SDKAuthorizationHandler):
224
228
  try:
225
229
  # Check if response body contains "not found" error
226
230
  if hasattr(response, "body"):
227
- import json
228
-
229
- body = json.loads(response.body)
231
+ body = json.loads(bytes(response.body))
230
232
  if (
231
233
  body.get("error") == "invalid_request"
232
234
  and "not found" in body.get("error_description", "").lower()
@@ -44,6 +44,7 @@ from mcp.server.auth.provider import (
44
44
  AccessToken,
45
45
  AuthorizationCode,
46
46
  AuthorizationParams,
47
+ AuthorizeError,
47
48
  RefreshToken,
48
49
  TokenError,
49
50
  )
@@ -309,10 +310,13 @@ def create_consent_html(
309
310
  """
310
311
 
311
312
  # Build form with buttons
313
+ # Use empty action to submit to current URL (/consent or /mcp/consent)
314
+ # The POST handler is registered at the same path as GET
312
315
  form = f"""
313
- <form id="consentForm" method="POST" action="/consent/submit">
316
+ <form id="consentForm" method="POST" action="">
314
317
  <input type="hidden" name="txn_id" value="{txn_id}" />
315
318
  <input type="hidden" name="csrf_token" value="{csrf_token}" />
319
+ <input type="hidden" name="submit" value="true" />
316
320
  <div class="button-group">
317
321
  <button type="submit" name="action" value="approve" class="btn-approve">Allow Access</button>
318
322
  <button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
@@ -365,7 +369,19 @@ def create_consent_html(
365
369
  )
366
370
 
367
371
  # Need to allow form-action for form submission
368
- csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; form-action *"
372
+ # Chrome requires explicit scheme declarations in CSP form-action when redirect chains
373
+ # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
374
+ parsed_redirect = urlparse(redirect_uri)
375
+ redirect_scheme = parsed_redirect.scheme.lower()
376
+
377
+ # Build form-action directive with standard schemes plus custom protocol if present
378
+ form_action_schemes = ["https:", "http:"]
379
+ if redirect_scheme and redirect_scheme not in ("http", "https"):
380
+ # Custom protocol scheme (e.g., cursor:, vscode:, etc.)
381
+ form_action_schemes.append(f"{redirect_scheme}:")
382
+
383
+ form_action_directive = " ".join(form_action_schemes)
384
+ csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
369
385
 
370
386
  return create_page(
371
387
  content=content,
@@ -375,6 +391,94 @@ def create_consent_html(
375
391
  )
376
392
 
377
393
 
394
+ def create_error_html(
395
+ error_title: str,
396
+ error_message: str,
397
+ error_details: dict[str, str] | None = None,
398
+ server_name: str | None = None,
399
+ server_icon_url: str | None = None,
400
+ ) -> str:
401
+ """Create a styled HTML error page for OAuth errors.
402
+
403
+ Args:
404
+ error_title: The error title (e.g., "OAuth Error", "Authorization Failed")
405
+ error_message: The main error message to display
406
+ error_details: Optional dictionary of error details to show (e.g., {"Error Code": "invalid_client"})
407
+ server_name: Optional server name to display
408
+ server_icon_url: Optional URL to server icon/logo
409
+
410
+ Returns:
411
+ Complete HTML page as a string
412
+ """
413
+ import html as html_module
414
+
415
+ error_message_escaped = html_module.escape(error_message)
416
+
417
+ # Build error message box
418
+ error_box = f"""
419
+ <div class="info-box error">
420
+ <p>{error_message_escaped}</p>
421
+ </div>
422
+ """
423
+
424
+ # Build error details section if provided
425
+ details_section = ""
426
+ if error_details:
427
+ detail_rows_html = "\n".join(
428
+ [
429
+ f"""
430
+ <div class="detail-row">
431
+ <div class="detail-label">{html_module.escape(label)}:</div>
432
+ <div class="detail-value">{html_module.escape(value)}</div>
433
+ </div>
434
+ """
435
+ for label, value in error_details.items()
436
+ ]
437
+ )
438
+
439
+ details_section = f"""
440
+ <details>
441
+ <summary>Error Details</summary>
442
+ <div class="detail-box">
443
+ {detail_rows_html}
444
+ </div>
445
+ </details>
446
+ """
447
+
448
+ # Build the page content
449
+ content = f"""
450
+ <div class="container">
451
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
452
+ <h1>{html_module.escape(error_title)}</h1>
453
+ {error_box}
454
+ {details_section}
455
+ </div>
456
+ """
457
+
458
+ # Additional styles needed for this page
459
+ # Override .info-box.error to use normal text color instead of red
460
+ additional_styles = (
461
+ INFO_BOX_STYLES
462
+ + DETAILS_STYLES
463
+ + DETAIL_BOX_STYLES
464
+ + """
465
+ .info-box.error {
466
+ color: #111827;
467
+ }
468
+ """
469
+ )
470
+
471
+ # Simple CSP policy for error pages (no forms needed)
472
+ csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'"
473
+
474
+ return create_page(
475
+ content=content,
476
+ title=error_title,
477
+ additional_styles=additional_styles,
478
+ csp_policy=csp_policy,
479
+ )
480
+
481
+
378
482
  # -------------------------------------------------------------------------
379
483
  # Handler Classes
380
484
  # -------------------------------------------------------------------------
@@ -836,6 +940,8 @@ class OAuthProxy(OAuthProvider):
836
940
  """
837
941
 
838
942
  # Create a ProxyDCRClient with configured redirect URI validation
943
+ if client_info.client_id is None:
944
+ raise ValueError("client_id is required for client registration")
839
945
  proxy_client: ProxyDCRClient = ProxyDCRClient(
840
946
  client_id=client_info.client_id,
841
947
  client_secret=client_info.client_secret,
@@ -865,7 +971,7 @@ class OAuthProxy(OAuthProvider):
865
971
  logger.debug(
866
972
  "Registered client %s with %d redirect URIs",
867
973
  client_info.client_id,
868
- len(proxy_client.redirect_uris),
974
+ len(proxy_client.redirect_uris) if proxy_client.redirect_uris else 0,
869
975
  )
870
976
 
871
977
  # -------------------------------------------------------------------------
@@ -902,6 +1008,10 @@ class OAuthProxy(OAuthProvider):
902
1008
  )
903
1009
 
904
1010
  # Store transaction data for IdP callback processing
1011
+ if client.client_id is None:
1012
+ raise AuthorizeError(
1013
+ error="invalid_client", error_description="Client ID is required"
1014
+ )
905
1015
  transaction = OAuthTransaction(
906
1016
  txn_id=txn_id,
907
1017
  client_id=client.client_id,
@@ -980,6 +1090,10 @@ class OAuthProxy(OAuthProvider):
980
1090
  return None
981
1091
 
982
1092
  # Create authorization code object with PKCE challenge
1093
+ if client.client_id is None:
1094
+ raise AuthorizeError(
1095
+ error="invalid_client", error_description="Client ID is required"
1096
+ )
983
1097
  return AuthorizationCode(
984
1098
  code=authorization_code,
985
1099
  client_id=client.client_id,
@@ -1065,18 +1179,21 @@ class OAuthProxy(OAuthProvider):
1065
1179
  expires_at=time.time() + expires_in,
1066
1180
  token_type=idp_tokens.get("token_type", "Bearer"),
1067
1181
  scope=" ".join(authorization_code.scopes),
1068
- client_id=client.client_id,
1182
+ client_id=client.client_id or "",
1069
1183
  created_at=time.time(),
1070
1184
  raw_token_data=idp_tokens,
1071
1185
  )
1072
1186
  await self._upstream_token_store.put(
1073
1187
  key=upstream_token_id,
1074
1188
  value=upstream_token_set,
1075
- ttl=expires_in, # Auto-expire when access token expires
1189
+ ttl=refresh_expires_in
1190
+ or expires_in, # Auto-expire when refresh token, or access token expires
1076
1191
  )
1077
1192
  logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8])
1078
1193
 
1079
1194
  # Issue minimal FastMCP access token (just a reference via JTI)
1195
+ if client.client_id is None:
1196
+ raise TokenError("invalid_client", "Client ID is required")
1080
1197
  fastmcp_access_token = self._jwt_issuer.issue_access_token(
1081
1198
  client_id=client.client_id,
1082
1199
  scopes=authorization_code.scopes,
@@ -1222,6 +1339,7 @@ class OAuthProxy(OAuthProvider):
1222
1339
  url=self._upstream_token_endpoint,
1223
1340
  refresh_token=upstream_token_set.refresh_token,
1224
1341
  scope=" ".join(scopes) if scopes else None,
1342
+ **self._extra_token_params,
1225
1343
  )
1226
1344
  logger.debug("Successfully refreshed upstream token")
1227
1345
  except Exception as e:
@@ -1268,10 +1386,17 @@ class OAuthProxy(OAuthProvider):
1268
1386
  await self._upstream_token_store.put(
1269
1387
  key=upstream_token_set.upstream_token_id,
1270
1388
  value=upstream_token_set,
1271
- ttl=new_expires_in, # Auto-expire when refreshed access token expires
1389
+ ttl=new_refresh_expires_in
1390
+ or (
1391
+ int(upstream_token_set.refresh_token_expires_at - time.time())
1392
+ if upstream_token_set.refresh_token_expires_at
1393
+ else 60 * 60 * 24 * 30 # Default to 30 days if unknown
1394
+ ), # Auto-expire when refresh token expires
1272
1395
  )
1273
1396
 
1274
1397
  # Issue new minimal FastMCP access token (just a reference via JTI)
1398
+ if client.client_id is None:
1399
+ raise TokenError("invalid_client", "Client ID is required")
1275
1400
  new_access_jti = secrets.token_urlsafe(32)
1276
1401
  new_fastmcp_access = self._jwt_issuer.issue_access_token(
1277
1402
  client_id=client.client_id,
@@ -1503,9 +1628,10 @@ class OAuthProxy(OAuthProvider):
1503
1628
  ):
1504
1629
  authorize_route_found = True
1505
1630
  # Replace with our enhanced authorization handler
1631
+ # Note: self.base_url is guaranteed to be set in parent __init__
1506
1632
  authorize_handler = AuthorizationHandler(
1507
1633
  provider=self,
1508
- base_url=self.base_url,
1634
+ base_url=self.base_url, # ty: ignore[invalid-argument-type]
1509
1635
  server_name=None, # Could be extended to pass server metadata
1510
1636
  server_icon_url=None,
1511
1637
  )
@@ -1551,12 +1677,10 @@ class OAuthProxy(OAuthProvider):
1551
1677
  )
1552
1678
 
1553
1679
  # Add consent endpoints
1554
- custom_routes.append(
1555
- Route(path="/consent", endpoint=self._show_consent_page, methods=["GET"])
1556
- )
1680
+ # Handle both GET (show page) and POST (submit) at /consent
1557
1681
  custom_routes.append(
1558
1682
  Route(
1559
- path="/consent/submit", endpoint=self._submit_consent, methods=["POST"]
1683
+ path="/consent", endpoint=self._handle_consent, methods=["GET", "POST"]
1560
1684
  )
1561
1685
  )
1562
1686
 
@@ -1569,7 +1693,9 @@ class OAuthProxy(OAuthProvider):
1569
1693
  # IdP Callback Forwarding
1570
1694
  # -------------------------------------------------------------------------
1571
1695
 
1572
- async def _handle_idp_callback(self, request: Request) -> RedirectResponse:
1696
+ async def _handle_idp_callback(
1697
+ self, request: Request
1698
+ ) -> HTMLResponse | RedirectResponse:
1573
1699
  """Handle callback from upstream IdP and forward to client.
1574
1700
 
1575
1701
  This implements the DCR-compliant callback forwarding:
@@ -1584,32 +1710,37 @@ class OAuthProxy(OAuthProvider):
1584
1710
  error = request.query_params.get("error")
1585
1711
 
1586
1712
  if error:
1713
+ error_description = request.query_params.get("error_description")
1587
1714
  logger.error(
1588
1715
  "IdP callback error: %s - %s",
1589
1716
  error,
1590
- request.query_params.get("error_description"),
1717
+ error_description,
1591
1718
  )
1592
- # TODO: Forward error to client callback
1593
- return RedirectResponse(
1594
- url=f"data:text/html,<h1>OAuth Error</h1><p>{error}: {request.query_params.get('error_description', 'Unknown error')}</p>",
1595
- status_code=302,
1719
+ # Show error page to user
1720
+ html_content = create_error_html(
1721
+ error_title="OAuth Error",
1722
+ error_message=f"Authentication failed: {error_description or 'Unknown error'}",
1723
+ error_details={"Error Code": error} if error else None,
1596
1724
  )
1725
+ return HTMLResponse(content=html_content, status_code=400)
1597
1726
 
1598
1727
  if not idp_code or not txn_id:
1599
1728
  logger.error("IdP callback missing code or transaction ID")
1600
- return RedirectResponse(
1601
- url="data:text/html,<h1>OAuth Error</h1><p>Missing authorization code or transaction ID</p>",
1602
- status_code=302,
1729
+ html_content = create_error_html(
1730
+ error_title="OAuth Error",
1731
+ error_message="Missing authorization code or transaction ID from the identity provider.",
1603
1732
  )
1733
+ return HTMLResponse(content=html_content, status_code=400)
1604
1734
 
1605
1735
  # Look up transaction data
1606
1736
  transaction_model = await self._transaction_store.get(key=txn_id)
1607
1737
  if not transaction_model:
1608
1738
  logger.error("IdP callback with invalid transaction ID: %s", txn_id)
1609
- return RedirectResponse(
1610
- url="data:text/html,<h1>OAuth Error</h1><p>Invalid or expired transaction</p>",
1611
- status_code=302,
1739
+ html_content = create_error_html(
1740
+ error_title="OAuth Error",
1741
+ error_message="Invalid or expired authorization transaction. Please try authenticating again.",
1612
1742
  )
1743
+ return HTMLResponse(content=html_content, status_code=400)
1613
1744
  transaction = transaction_model.model_dump()
1614
1745
 
1615
1746
  # Exchange IdP code for tokens (server-side)
@@ -1663,11 +1794,11 @@ class OAuthProxy(OAuthProvider):
1663
1794
 
1664
1795
  except Exception as e:
1665
1796
  logger.error("IdP token exchange failed: %s", e)
1666
- # TODO: Forward error to client callback
1667
- return RedirectResponse(
1668
- url=f"data:text/html,<h1>OAuth Error</h1><p>Token exchange failed: {e}</p>",
1669
- status_code=302,
1797
+ html_content = create_error_html(
1798
+ error_title="OAuth Error",
1799
+ error_message=f"Token exchange with identity provider failed: {e}",
1670
1800
  )
1801
+ return HTMLResponse(content=html_content, status_code=500)
1671
1802
 
1672
1803
  # Generate our own authorization code for the client
1673
1804
  client_code = secrets.token_urlsafe(32)
@@ -1714,10 +1845,11 @@ class OAuthProxy(OAuthProvider):
1714
1845
 
1715
1846
  except Exception as e:
1716
1847
  logger.error("Error in IdP callback handler: %s", e, exc_info=True)
1717
- return RedirectResponse(
1718
- url="data:text/html,<h1>OAuth Error</h1><p>Internal server error during IdP callback</p>",
1719
- status_code=302,
1848
+ html_content = create_error_html(
1849
+ error_title="OAuth Error",
1850
+ error_message="Internal server error during OAuth callback processing. Please try again.",
1720
1851
  )
1852
+ return HTMLResponse(content=html_content, status_code=500)
1721
1853
 
1722
1854
  # -------------------------------------------------------------------------
1723
1855
  # Consent Interstitial
@@ -1863,6 +1995,14 @@ class OAuthProxy(OAuthProvider):
1863
1995
  separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
1864
1996
  return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
1865
1997
 
1998
+ async def _handle_consent(
1999
+ self, request: Request
2000
+ ) -> HTMLResponse | RedirectResponse:
2001
+ """Handle consent page - dispatch to GET or POST handler based on method."""
2002
+ if request.method == "POST":
2003
+ return await self._submit_consent(request)
2004
+ return await self._show_consent_page(request)
2005
+
1866
2006
  async def _show_consent_page(
1867
2007
  self, request: Request
1868
2008
  ) -> HTMLResponse | RedirectResponse:
@@ -206,6 +206,7 @@ class OIDCProxy(OAuthProxy):
206
206
  audience: str | None = None,
207
207
  timeout_seconds: int | None = None,
208
208
  # Token verifier
209
+ token_verifier: TokenVerifier | None = None,
209
210
  algorithm: str | None = None,
210
211
  required_scopes: list[str] | None = None,
211
212
  # FastMCP server configuration
@@ -231,8 +232,11 @@ class OIDCProxy(OAuthProxy):
231
232
  client_secret: Client secret for upstream server
232
233
  audience: Audience for upstream server
233
234
  timeout_seconds: HTTP request timeout in seconds
234
- algorithm: Token verifier algorithm
235
- required_scopes: Required OAuth scopes
235
+ token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens).
236
+ If not provided, a JWTVerifier will be created using the OIDC configuration.
237
+ Cannot be used with algorithm or required_scopes parameters (configure these on your verifier instead).
238
+ algorithm: Token verifier algorithm (only used if token_verifier is not provided)
239
+ required_scopes: Required scopes for token validation (only used if token_verifier is not provided)
236
240
  base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
237
241
  issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
238
242
  to avoid 404s during discovery when mounting under a path.
@@ -268,6 +272,19 @@ class OIDCProxy(OAuthProxy):
268
272
  if not base_url:
269
273
  raise ValueError("Missing required base URL")
270
274
 
275
+ # Validate that verifier-specific parameters are not used with custom verifier
276
+ if token_verifier is not None:
277
+ if algorithm is not None:
278
+ raise ValueError(
279
+ "Cannot specify 'algorithm' when providing a custom token_verifier. "
280
+ "Configure the algorithm on your token verifier instead."
281
+ )
282
+ if required_scopes is not None:
283
+ raise ValueError(
284
+ "Cannot specify 'required_scopes' when providing a custom token_verifier. "
285
+ "Configure required scopes on your token verifier instead."
286
+ )
287
+
271
288
  if isinstance(config_url, str):
272
289
  config_url = AnyHttpUrl(config_url)
273
290
 
@@ -287,12 +304,14 @@ class OIDCProxy(OAuthProxy):
287
304
  else None
288
305
  )
289
306
 
290
- token_verifier = self.get_token_verifier(
291
- algorithm=algorithm,
292
- audience=audience,
293
- required_scopes=required_scopes,
294
- timeout_seconds=timeout_seconds,
295
- )
307
+ # Use custom verifier if provided, otherwise create default JWTVerifier
308
+ if token_verifier is None:
309
+ token_verifier = self.get_token_verifier(
310
+ algorithm=algorithm,
311
+ audience=audience,
312
+ required_scopes=required_scopes,
313
+ timeout_seconds=timeout_seconds,
314
+ )
296
315
 
297
316
  init_kwargs = {
298
317
  "upstream_authorization_endpoint": str(
@@ -321,7 +340,7 @@ class OIDCProxy(OAuthProxy):
321
340
  init_kwargs["extra_authorize_params"] = extra_params
322
341
  init_kwargs["extra_token_params"] = extra_params
323
342
 
324
- super().__init__(**init_kwargs)
343
+ super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type]
325
344
 
326
345
  def get_oidc_configuration(
327
346
  self,
@@ -46,6 +46,7 @@ class AzureProviderSettings(BaseSettings):
46
46
  additional_authorize_scopes: list[str] | None = None
47
47
  allowed_client_redirect_uris: list[str] | None = None
48
48
  jwt_signing_key: str | None = None
49
+ base_authority: str = "login.microsoftonline.com"
49
50
 
50
51
  @field_validator("required_scopes", mode="before")
51
52
  @classmethod
@@ -93,6 +94,7 @@ class AzureProvider(OAuthProxy):
93
94
  from fastmcp import FastMCP
94
95
  from fastmcp.server.auth.providers.azure import AzureProvider
95
96
 
97
+ # Standard Azure (Public Cloud)
96
98
  auth = AzureProvider(
97
99
  client_id="your-client-id",
98
100
  client_secret="your-client-secret",
@@ -103,6 +105,16 @@ class AzureProvider(OAuthProxy):
103
105
  # identifier_uri defaults to api://{client_id}
104
106
  )
105
107
 
108
+ # Azure Government
109
+ auth_gov = AzureProvider(
110
+ client_id="your-client-id",
111
+ client_secret="your-client-secret",
112
+ tenant_id="your-tenant-id",
113
+ required_scopes=["read", "write"],
114
+ base_authority="login.microsoftonline.us", # Override for Azure Gov
115
+ base_url="http://localhost:8000",
116
+ )
117
+
106
118
  mcp = FastMCP("My App", auth=auth)
107
119
  ```
108
120
  """
@@ -123,6 +135,7 @@ class AzureProvider(OAuthProxy):
123
135
  client_storage: AsyncKeyValue | None = None,
124
136
  jwt_signing_key: str | bytes | NotSetT = NotSet,
125
137
  require_authorization_consent: bool = True,
138
+ base_authority: str | NotSetT = NotSet,
126
139
  ) -> None:
127
140
  """Initialize Azure OAuth provider.
128
141
 
@@ -138,6 +151,8 @@ class AzureProvider(OAuthProxy):
138
151
  issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
139
152
  to avoid 404s during discovery when mounting under a path.
140
153
  redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
154
+ base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
155
+ For Azure Government, use "login.microsoftonline.us".
141
156
  required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
142
157
  - Automatically prefixed with identifier_uri during initialization
143
158
  - Validated on all tokens
@@ -180,6 +195,7 @@ class AzureProvider(OAuthProxy):
180
195
  "additional_authorize_scopes": additional_authorize_scopes,
181
196
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
182
197
  "jwt_signing_key": jwt_signing_key,
198
+ "base_authority": base_authority,
183
199
  }.items()
184
200
  if v is not NotSet
185
201
  }
@@ -218,9 +234,10 @@ class AzureProvider(OAuthProxy):
218
234
  tenant_id_final = settings.tenant_id
219
235
 
220
236
  # Always validate tokens against the app's API client ID using JWT
221
- issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0"
237
+ base_authority_final = settings.base_authority
238
+ issuer = f"https://{base_authority_final}/{tenant_id_final}/v2.0"
222
239
  jwks_uri = (
223
- f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys"
240
+ f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
224
241
  )
225
242
 
226
243
  # Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
@@ -239,10 +256,10 @@ class AzureProvider(OAuthProxy):
239
256
 
240
257
  # Build Azure OAuth endpoints with tenant
241
258
  authorization_endpoint = (
242
- f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize"
259
+ f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/authorize"
243
260
  )
244
261
  token_endpoint = (
245
- f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/token"
262
+ f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/token"
246
263
  )
247
264
 
248
265
  # Initialize OAuth proxy with Azure endpoints
@@ -262,11 +279,15 @@ class AzureProvider(OAuthProxy):
262
279
  require_authorization_consent=require_authorization_consent,
263
280
  )
264
281
 
282
+ authority_info = ""
283
+ if base_authority_final != "login.microsoftonline.com":
284
+ authority_info = f" using authority {base_authority_final}"
265
285
  logger.info(
266
- "Initialized Azure OAuth provider for client %s with tenant %s%s",
286
+ "Initialized Azure OAuth provider for client %s with tenant %s%s%s",
267
287
  settings.client_id,
268
288
  tenant_id_final,
269
289
  f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
290
+ authority_info,
270
291
  )
271
292
 
272
293
  async def authorize(
@@ -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