fastmcp 2.13.3__py3-none-any.whl → 2.14.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 (85) hide show
  1. fastmcp/__init__.py +0 -21
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +8 -22
  5. fastmcp/cli/install/shared.py +0 -15
  6. fastmcp/cli/tasks.py +110 -0
  7. fastmcp/client/auth/oauth.py +9 -9
  8. fastmcp/client/client.py +739 -136
  9. fastmcp/client/elicitation.py +11 -5
  10. fastmcp/client/messages.py +7 -5
  11. fastmcp/client/roots.py +2 -1
  12. fastmcp/client/sampling/__init__.py +69 -0
  13. fastmcp/client/sampling/handlers/__init__.py +0 -0
  14. fastmcp/client/sampling/handlers/anthropic.py +387 -0
  15. fastmcp/client/sampling/handlers/openai.py +399 -0
  16. fastmcp/client/tasks.py +551 -0
  17. fastmcp/client/transports.py +72 -21
  18. fastmcp/contrib/component_manager/component_service.py +4 -20
  19. fastmcp/dependencies.py +25 -0
  20. fastmcp/experimental/sampling/handlers/__init__.py +5 -0
  21. fastmcp/experimental/sampling/handlers/openai.py +4 -169
  22. fastmcp/experimental/server/openapi/__init__.py +15 -13
  23. fastmcp/experimental/utilities/openapi/__init__.py +12 -38
  24. fastmcp/prompts/prompt.py +38 -38
  25. fastmcp/resources/resource.py +33 -16
  26. fastmcp/resources/template.py +69 -59
  27. fastmcp/server/auth/__init__.py +0 -9
  28. fastmcp/server/auth/auth.py +127 -3
  29. fastmcp/server/auth/oauth_proxy.py +47 -97
  30. fastmcp/server/auth/oidc_proxy.py +7 -0
  31. fastmcp/server/auth/providers/in_memory.py +2 -2
  32. fastmcp/server/auth/providers/oci.py +2 -2
  33. fastmcp/server/context.py +509 -180
  34. fastmcp/server/dependencies.py +464 -6
  35. fastmcp/server/elicitation.py +285 -47
  36. fastmcp/server/event_store.py +177 -0
  37. fastmcp/server/http.py +15 -3
  38. fastmcp/server/low_level.py +56 -12
  39. fastmcp/server/middleware/middleware.py +2 -2
  40. fastmcp/server/openapi/__init__.py +35 -0
  41. fastmcp/{experimental/server → server}/openapi/components.py +4 -3
  42. fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
  43. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  44. fastmcp/server/proxy.py +53 -40
  45. fastmcp/server/sampling/__init__.py +10 -0
  46. fastmcp/server/sampling/run.py +301 -0
  47. fastmcp/server/sampling/sampling_tool.py +108 -0
  48. fastmcp/server/server.py +793 -552
  49. fastmcp/server/tasks/__init__.py +21 -0
  50. fastmcp/server/tasks/capabilities.py +22 -0
  51. fastmcp/server/tasks/config.py +89 -0
  52. fastmcp/server/tasks/converters.py +206 -0
  53. fastmcp/server/tasks/handlers.py +356 -0
  54. fastmcp/server/tasks/keys.py +93 -0
  55. fastmcp/server/tasks/protocol.py +355 -0
  56. fastmcp/server/tasks/subscriptions.py +205 -0
  57. fastmcp/settings.py +101 -103
  58. fastmcp/tools/tool.py +83 -49
  59. fastmcp/tools/tool_transform.py +1 -12
  60. fastmcp/utilities/components.py +3 -3
  61. fastmcp/utilities/json_schema_type.py +4 -4
  62. fastmcp/utilities/mcp_config.py +1 -2
  63. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  64. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  65. fastmcp/utilities/openapi/__init__.py +63 -0
  66. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  67. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
  68. fastmcp/utilities/tests.py +11 -5
  69. fastmcp/utilities/types.py +8 -0
  70. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
  71. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
  72. fastmcp/client/sampling.py +0 -56
  73. fastmcp/experimental/sampling/handlers/base.py +0 -21
  74. fastmcp/server/auth/providers/bearer.py +0 -25
  75. fastmcp/server/openapi.py +0 -1087
  76. fastmcp/server/sampling/handler.py +0 -19
  77. fastmcp/utilities/openapi.py +0 -1568
  78. /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  79. /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
  80. /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
  81. /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
  82. /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
  83. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
  84. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
  85. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from typing import Any, cast
5
+ from urllib.parse import urlparse
4
6
 
7
+ from mcp.server.auth.handlers.token import TokenErrorResponse
8
+ from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
9
+ from mcp.server.auth.json_response import PydanticJSONResponse
5
10
  from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
6
11
  from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
12
+ from mcp.server.auth.middleware.client_auth import ClientAuthenticator
7
13
  from mcp.server.auth.provider import (
8
14
  AccessToken as _SDKAccessToken,
9
15
  )
@@ -16,6 +22,7 @@ from mcp.server.auth.provider import (
16
22
  TokenVerifier as TokenVerifierProtocol,
17
23
  )
18
24
  from mcp.server.auth.routes import (
25
+ cors_middleware,
19
26
  create_auth_routes,
20
27
  create_protected_resource_routes,
21
28
  )
@@ -39,6 +46,48 @@ class AccessToken(_SDKAccessToken):
39
46
  claims: dict[str, Any] = Field(default_factory=dict)
40
47
 
41
48
 
49
+ class TokenHandler(_SDKTokenHandler):
50
+ """TokenHandler that returns OAuth 2.1 compliant error responses.
51
+
52
+ The MCP SDK returns `unauthorized_client` for client authentication failures.
53
+ However, per RFC 6749 Section 5.2, authentication failures should return
54
+ `invalid_client` with HTTP 401, not `unauthorized_client`.
55
+
56
+ This distinction matters: `unauthorized_client` means "client exists but
57
+ can't do this", while `invalid_client` means "client doesn't exist or
58
+ credentials are wrong". Claude's OAuth client uses this to decide whether
59
+ to re-register.
60
+
61
+ This handler transforms 401 responses with `unauthorized_client` to use
62
+ `invalid_client` instead, making the error semantics correct per OAuth spec.
63
+ """
64
+
65
+ async def handle(self, request: Any):
66
+ """Wrap SDK handle() and transform auth error responses."""
67
+ response = await super().handle(request)
68
+
69
+ # Transform 401 unauthorized_client -> invalid_client
70
+ if response.status_code == 401:
71
+ try:
72
+ body = json.loads(response.body)
73
+ if body.get("error") == "unauthorized_client":
74
+ return PydanticJSONResponse(
75
+ content=TokenErrorResponse(
76
+ error="invalid_client",
77
+ error_description=body.get("error_description"),
78
+ ),
79
+ status_code=401,
80
+ headers={
81
+ "Cache-Control": "no-store",
82
+ "Pragma": "no-cache",
83
+ },
84
+ )
85
+ except (json.JSONDecodeError, AttributeError):
86
+ pass # Not JSON or unexpected format, return as-is
87
+
88
+ return response
89
+
90
+
42
91
  class AuthProvider(TokenVerifierProtocol):
43
92
  """Base class for all FastMCP authentication providers.
44
93
 
@@ -140,12 +189,13 @@ class AuthProvider(TokenVerifierProtocol):
140
189
  Returns:
141
190
  List of Starlette Middleware instances to apply to the HTTP app
142
191
  """
192
+ # TODO(ty): remove type ignores when ty supports Starlette Middleware typing
143
193
  return [
144
194
  Middleware(
145
- AuthenticationMiddleware,
195
+ AuthenticationMiddleware, # type: ignore[arg-type]
146
196
  backend=BearerAuthBackend(self),
147
197
  ),
148
- Middleware(AuthContextMiddleware),
198
+ Middleware(AuthContextMiddleware), # type: ignore[arg-type]
149
199
  ]
150
200
 
151
201
  def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None:
@@ -367,7 +417,7 @@ class OAuthProvider(
367
417
  self.issuer_url is not None
368
418
  ) # typing check (issuer_url defaults to base_url)
369
419
 
370
- oauth_routes = create_auth_routes(
420
+ sdk_routes = create_auth_routes(
371
421
  provider=self,
372
422
  issuer_url=self.base_url,
373
423
  service_documentation_url=self.service_documentation_url,
@@ -375,6 +425,32 @@ class OAuthProvider(
375
425
  revocation_options=self.revocation_options,
376
426
  )
377
427
 
428
+ # Replace the token endpoint with our custom handler that returns
429
+ # proper OAuth 2.1 error codes (invalid_client instead of unauthorized_client)
430
+ oauth_routes: list[Route] = []
431
+ for route in sdk_routes:
432
+ if (
433
+ isinstance(route, Route)
434
+ and route.path == "/token"
435
+ and route.methods is not None
436
+ and "POST" in route.methods
437
+ ):
438
+ # Replace with our OAuth 2.1 compliant token handler
439
+ token_handler = TokenHandler(
440
+ provider=self, client_authenticator=ClientAuthenticator(self)
441
+ )
442
+ oauth_routes.append(
443
+ Route(
444
+ path="/token",
445
+ endpoint=cors_middleware(
446
+ token_handler.handle, ["POST", "OPTIONS"]
447
+ ),
448
+ methods=["POST", "OPTIONS"],
449
+ )
450
+ )
451
+ else:
452
+ oauth_routes.append(route)
453
+
378
454
  # Get the resource URL based on the MCP path
379
455
  resource_url = self._get_resource_url(mcp_path)
380
456
 
@@ -397,3 +473,51 @@ class OAuthProvider(
397
473
  oauth_routes.extend(super().get_routes(mcp_path))
398
474
 
399
475
  return oauth_routes
476
+
477
+ def get_well_known_routes(
478
+ self,
479
+ mcp_path: str | None = None,
480
+ ) -> list[Route]:
481
+ """Get well-known discovery routes with RFC 8414 path-aware support.
482
+
483
+ Overrides the base implementation to support path-aware authorization
484
+ server metadata discovery per RFC 8414. If issuer_url has a path component,
485
+ the authorization server metadata route is adjusted to include that path.
486
+
487
+ For example, if issuer_url is "http://example.com/api", the discovery
488
+ endpoint will be at "/.well-known/oauth-authorization-server/api" instead
489
+ of just "/.well-known/oauth-authorization-server".
490
+
491
+ Args:
492
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
493
+
494
+ Returns:
495
+ List of well-known discovery routes
496
+ """
497
+ routes = super().get_well_known_routes(mcp_path)
498
+
499
+ # RFC 8414: If issuer_url has a path, use path-aware discovery
500
+ if self.issuer_url:
501
+ parsed = urlparse(str(self.issuer_url))
502
+ issuer_path = parsed.path.rstrip("/")
503
+
504
+ if issuer_path and issuer_path != "/":
505
+ # Replace /.well-known/oauth-authorization-server with path-aware version
506
+ new_routes = []
507
+ for route in routes:
508
+ if route.path == "/.well-known/oauth-authorization-server":
509
+ new_path = (
510
+ f"/.well-known/oauth-authorization-server{issuer_path}"
511
+ )
512
+ new_routes.append(
513
+ Route(
514
+ new_path,
515
+ endpoint=route.endpoint,
516
+ methods=route.methods,
517
+ )
518
+ )
519
+ else:
520
+ new_routes.append(route)
521
+ return new_routes
522
+
523
+ return routes
@@ -36,10 +36,6 @@ from key_value.aio.adapters.pydantic import PydanticAdapter
36
36
  from key_value.aio.protocols import AsyncKeyValue
37
37
  from key_value.aio.stores.disk import DiskStore
38
38
  from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
39
- from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse
40
- from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
41
- from mcp.server.auth.json_response import PydanticJSONResponse
42
- from mcp.server.auth.middleware.client_auth import ClientAuthenticator
43
39
  from mcp.server.auth.provider import (
44
40
  AccessToken,
45
41
  AuthorizationCode,
@@ -48,7 +44,6 @@ from mcp.server.auth.provider import (
48
44
  RefreshToken,
49
45
  TokenError,
50
46
  )
51
- from mcp.server.auth.routes import cors_middleware
52
47
  from mcp.server.auth.settings import (
53
48
  ClientRegistrationOptions,
54
49
  RevocationOptions,
@@ -95,6 +90,9 @@ logger = get_logger(__name__)
95
90
 
96
91
  # Default token expiration times
97
92
  DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour
93
+ DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = (
94
+ 60 * 60 * 24 * 365
95
+ ) # 1 year
98
96
  DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
99
97
 
100
98
  # HTTP client timeout
@@ -438,7 +436,7 @@ def create_error_html(
438
436
  Args:
439
437
  error_title: The error title (e.g., "OAuth Error", "Authorization Failed")
440
438
  error_message: The main error message to display
441
- error_details: Optional dictionary of error details to show (e.g., {"Error Code": "invalid_client"})
439
+ error_details: Optional dictionary of error details to show (e.g., `{"Error Code": "invalid_client"}`)
442
440
  server_name: Optional server name to display
443
441
  server_icon_url: Optional URL to server icon/logo
444
442
 
@@ -514,60 +512,6 @@ def create_error_html(
514
512
  )
515
513
 
516
514
 
517
- # -------------------------------------------------------------------------
518
- # Handler Classes
519
- # -------------------------------------------------------------------------
520
-
521
-
522
- class TokenHandler(_SDKTokenHandler):
523
- """TokenHandler that returns OAuth 2.1 compliant error responses.
524
-
525
- The MCP SDK always returns HTTP 400 for all client authentication issues.
526
- However, OAuth 2.1 Section 5.3 and the MCP specification require that
527
- invalid or expired tokens MUST receive a HTTP 401 response.
528
-
529
- This handler extends the base MCP SDK TokenHandler to transform client
530
- authentication failures into OAuth 2.1 compliant responses:
531
- - Changes 'unauthorized_client' to 'invalid_client' error code
532
- - Returns HTTP 401 status code instead of 400 for client auth failures
533
-
534
- Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401
535
- (Unauthorized) status code to indicate which HTTP authentication schemes
536
- are supported."
537
-
538
- Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response."
539
- """
540
-
541
- def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
542
- """Override response method to provide OAuth 2.1 compliant error handling."""
543
- # Check if this is a client authentication failure (not just unauthorized for grant type)
544
- # unauthorized_client can mean two things:
545
- # 1. Client authentication failed (client_id not found or wrong credentials) -> invalid_client 401
546
- # 2. Client not authorized for this grant type -> unauthorized_client 400 (correct per spec)
547
- if (
548
- isinstance(obj, TokenErrorResponse)
549
- and obj.error == "unauthorized_client"
550
- and obj.error_description
551
- and "Invalid client_id" in obj.error_description
552
- ):
553
- # Transform client auth failure to OAuth 2.1 compliant response
554
- return PydanticJSONResponse(
555
- content=TokenErrorResponse(
556
- error="invalid_client",
557
- error_description=obj.error_description,
558
- error_uri=obj.error_uri,
559
- ),
560
- status_code=401,
561
- headers={
562
- "Cache-Control": "no-store",
563
- "Pragma": "no-cache",
564
- },
565
- )
566
-
567
- # Otherwise use default behavior from parent class
568
- return super().response(obj)
569
-
570
-
571
515
  class OAuthProxy(OAuthProvider):
572
516
  """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs.
573
517
 
@@ -712,6 +656,8 @@ class OAuthProxy(OAuthProvider):
712
656
  # Consent screen configuration
713
657
  require_authorization_consent: bool = True,
714
658
  consent_csp_policy: str | None = None,
659
+ # Token expiry fallback
660
+ fallback_access_token_expiry_seconds: int | None = None,
715
661
  ):
716
662
  """Initialize the OAuth proxy provider.
717
663
 
@@ -761,6 +707,11 @@ class OAuthProxy(OAuthProvider):
761
707
  If a non-empty string, uses that as the CSP policy value.
762
708
  This allows organizations with their own CSP policies to override or disable
763
709
  the built-in CSP directives.
710
+ fallback_access_token_expiry_seconds: Expiry time to use when upstream provider
711
+ doesn't return `expires_in` in the token response. If not set, uses smart
712
+ defaults: 1 hour if a refresh token is available (since we can refresh),
713
+ or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
714
+ Set explicitly to override these defaults.
764
715
  """
765
716
 
766
717
  # Always enable DCR since we implement it locally for MCP clients
@@ -832,6 +783,11 @@ class OAuthProxy(OAuthProvider):
832
783
  self._extra_authorize_params: dict[str, str] = extra_authorize_params or {}
833
784
  self._extra_token_params: dict[str, str] = extra_token_params or {}
834
785
 
786
+ # Token expiry fallback (None means use smart default based on refresh token)
787
+ self._fallback_access_token_expiry_seconds: int | None = (
788
+ fallback_access_token_expiry_seconds
789
+ )
790
+
835
791
  if jwt_signing_key is None:
836
792
  jwt_signing_key = derive_jwt_key(
837
793
  high_entropy_material=upstream_client_secret,
@@ -993,9 +949,13 @@ class OAuthProxy(OAuthProvider):
993
949
  # Create a ProxyDCRClient with configured redirect URI validation
994
950
  if client_info.client_id is None:
995
951
  raise ValueError("client_id is required for client registration")
952
+ # We use token_endpoint_auth_method="none" because the proxy handles
953
+ # all upstream authentication. The client_secret must also be None
954
+ # because the SDK requires secrets to be provided if they're set,
955
+ # regardless of auth method.
996
956
  proxy_client: ProxyDCRClient = ProxyDCRClient(
997
957
  client_id=client_info.client_id,
998
- client_secret=client_info.client_secret,
958
+ client_secret=None,
999
959
  redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
1000
960
  grant_types=client_info.grant_types
1001
961
  or ["authorization_code", "refresh_token"],
@@ -1061,7 +1021,8 @@ class OAuthProxy(OAuthProvider):
1061
1021
  # Store transaction data for IdP callback processing
1062
1022
  if client.client_id is None:
1063
1023
  raise AuthorizeError(
1064
- error="invalid_client", error_description="Client ID is required"
1024
+ error="invalid_client", # type: ignore[arg-type]
1025
+ error_description="Client ID is required",
1065
1026
  )
1066
1027
  transaction = OAuthTransaction(
1067
1028
  txn_id=txn_id,
@@ -1143,7 +1104,8 @@ class OAuthProxy(OAuthProvider):
1143
1104
  # Create authorization code object with PKCE challenge
1144
1105
  if client.client_id is None:
1145
1106
  raise AuthorizeError(
1146
- error="invalid_client", error_description="Client ID is required"
1107
+ error="invalid_client", # type: ignore[arg-type]
1108
+ error_description="Client ID is required",
1147
1109
  )
1148
1110
  return AuthorizationCode(
1149
1111
  code=authorization_code,
@@ -1195,9 +1157,18 @@ class OAuthProxy(OAuthProvider):
1195
1157
  )
1196
1158
 
1197
1159
  # Calculate token expiry times
1198
- expires_in = int(
1199
- idp_tokens.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
1200
- )
1160
+ # If upstream provides expires_in, use it. Otherwise use fallback based on:
1161
+ # - User-provided fallback if set
1162
+ # - 1 hour if refresh token available (can refresh when expired)
1163
+ # - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps)
1164
+ if "expires_in" in idp_tokens:
1165
+ expires_in = int(idp_tokens["expires_in"])
1166
+ elif self._fallback_access_token_expiry_seconds is not None:
1167
+ expires_in = self._fallback_access_token_expiry_seconds
1168
+ elif idp_tokens.get("refresh_token"):
1169
+ expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
1170
+ else:
1171
+ expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS
1201
1172
 
1202
1173
  # Calculate refresh token expiry if provided by upstream
1203
1174
  # Some providers include refresh_expires_in, some don't
@@ -1434,9 +1405,14 @@ class OAuthProxy(OAuthProvider):
1434
1405
  raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e
1435
1406
 
1436
1407
  # Update stored upstream token
1437
- new_expires_in = int(
1438
- token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
1439
- )
1408
+ # In refresh flow, we know there's a refresh token, so default to 1 hour
1409
+ # (user override still applies if set)
1410
+ if "expires_in" in token_response:
1411
+ new_expires_in = int(token_response["expires_in"])
1412
+ elif self._fallback_access_token_expiry_seconds is not None:
1413
+ new_expires_in = self._fallback_access_token_expiry_seconds
1414
+ else:
1415
+ new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
1440
1416
  upstream_token_set.access_token = token_response["access_token"]
1441
1417
  upstream_token_set.expires_at = time.time() + new_expires_in
1442
1418
 
@@ -1567,7 +1543,7 @@ class OAuthProxy(OAuthProvider):
1567
1543
  # Token Validation
1568
1544
  # -------------------------------------------------------------------------
1569
1545
 
1570
- async def load_access_token(self, token: str) -> AccessToken | None:
1546
+ async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override]
1571
1547
  """Validate FastMCP JWT by swapping for upstream token.
1572
1548
 
1573
1549
  This implements the token swap pattern:
@@ -1671,10 +1647,9 @@ class OAuthProxy(OAuthProvider):
1671
1647
  This is used to advertise the resource URL in metadata.
1672
1648
  """
1673
1649
  # Get standard OAuth routes from parent class
1650
+ # Note: parent already replaces /token with TokenHandler for proper error codes
1674
1651
  routes = super().get_routes(mcp_path)
1675
1652
  custom_routes = []
1676
- token_route_found = False
1677
- authorize_route_found = False
1678
1653
 
1679
1654
  logger.debug(
1680
1655
  f"get_routes called - configuring OAuth routes in {len(routes)} routes"
@@ -1692,7 +1667,6 @@ class OAuthProxy(OAuthProvider):
1692
1667
  and route.methods is not None
1693
1668
  and ("GET" in route.methods or "POST" in route.methods)
1694
1669
  ):
1695
- authorize_route_found = True
1696
1670
  # Replace with our enhanced authorization handler
1697
1671
  # Note: self.base_url is guaranteed to be set in parent __init__
1698
1672
  authorize_handler = AuthorizationHandler(
@@ -1708,27 +1682,6 @@ class OAuthProxy(OAuthProvider):
1708
1682
  methods=["GET", "POST"],
1709
1683
  )
1710
1684
  )
1711
- # Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes
1712
- elif (
1713
- isinstance(route, Route)
1714
- and route.path == "/token"
1715
- and route.methods is not None
1716
- and "POST" in route.methods
1717
- ):
1718
- token_route_found = True
1719
- # Replace with our OAuth 2.1 compliant token handler
1720
- token_handler = TokenHandler(
1721
- provider=self, client_authenticator=ClientAuthenticator(self)
1722
- )
1723
- custom_routes.append(
1724
- Route(
1725
- path="/token",
1726
- endpoint=cors_middleware(
1727
- token_handler.handle, ["POST", "OPTIONS"]
1728
- ),
1729
- methods=["POST", "OPTIONS"],
1730
- )
1731
- )
1732
1685
  else:
1733
1686
  # Keep all other standard OAuth routes unchanged
1734
1687
  custom_routes.append(route)
@@ -1750,9 +1703,6 @@ class OAuthProxy(OAuthProvider):
1750
1703
  )
1751
1704
  )
1752
1705
 
1753
- logger.debug(
1754
- f"✅ OAuth routes configured: authorize_endpoint={authorize_route_found}, token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)"
1755
- )
1756
1706
  return custom_routes
1757
1707
 
1758
1708
  # -------------------------------------------------------------------------
@@ -226,6 +226,8 @@ class OIDCProxy(OAuthProxy):
226
226
  # Extra parameters
227
227
  extra_authorize_params: dict[str, str] | None = None,
228
228
  extra_token_params: dict[str, str] | None = None,
229
+ # Token expiry fallback
230
+ fallback_access_token_expiry_seconds: int | None = None,
229
231
  ) -> None:
230
232
  """Initialize the OIDC proxy provider.
231
233
 
@@ -272,6 +274,10 @@ class OIDCProxy(OAuthProxy):
272
274
  Example: {"prompt": "consent", "access_type": "offline"}
273
275
  extra_token_params: Additional parameters to forward to the upstream token endpoint.
274
276
  Useful for provider-specific parameters during token exchange.
277
+ fallback_access_token_expiry_seconds: Expiry time to use when upstream provider
278
+ doesn't return `expires_in` in the token response. If not set, uses smart
279
+ defaults: 1 hour if a refresh token is available (since we can refresh),
280
+ or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
275
281
  """
276
282
  if not config_url:
277
283
  raise ValueError("Missing required config URL")
@@ -344,6 +350,7 @@ class OIDCProxy(OAuthProxy):
344
350
  "token_endpoint_auth_method": token_endpoint_auth_method,
345
351
  "require_authorization_consent": require_authorization_consent,
346
352
  "consent_csp_policy": consent_csp_policy,
353
+ "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,
347
354
  }
348
355
 
349
356
  if redirect_path:
@@ -284,7 +284,7 @@ class InMemoryOAuthProvider(OAuthProvider):
284
284
  scope=" ".join(scopes),
285
285
  )
286
286
 
287
- async def load_access_token(self, token: str) -> AccessToken | None:
287
+ async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override]
288
288
  token_obj = self.access_tokens.get(token)
289
289
  if token_obj:
290
290
  if token_obj.expires_at is not None and token_obj.expires_at < time.time():
@@ -295,7 +295,7 @@ class InMemoryOAuthProvider(OAuthProvider):
295
295
  return token_obj
296
296
  return None
297
297
 
298
- async def verify_token(self, token: str) -> AccessToken | None:
298
+ async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override]
299
299
  """
300
300
  Verify a bearer token and return access info if valid.
301
301
 
@@ -171,7 +171,7 @@ class OCIProvider(OIDCProxy):
171
171
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
172
172
  """
173
173
 
174
- overrides = {
174
+ overrides: dict[str, object] = {
175
175
  k: v
176
176
  for k, v in {
177
177
  "config_url": config_url,
@@ -187,7 +187,7 @@ class OCIProvider(OIDCProxy):
187
187
  }.items()
188
188
  if v is not NotSet
189
189
  }
190
- settings = OCIProviderSettings(**overrides)
190
+ settings = OCIProviderSettings(**overrides) # type: ignore[arg-type]
191
191
 
192
192
  if not settings.config_url:
193
193
  raise ValueError(