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.
- fastmcp/__init__.py +0 -21
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +8 -22
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/auth/oauth.py +9 -9
- fastmcp/client/client.py +739 -136
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling/__init__.py +69 -0
- fastmcp/client/sampling/handlers/__init__.py +0 -0
- fastmcp/client/sampling/handlers/anthropic.py +387 -0
- fastmcp/client/sampling/handlers/openai.py +399 -0
- fastmcp/client/tasks.py +551 -0
- fastmcp/client/transports.py +72 -21
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/__init__.py +5 -0
- fastmcp/experimental/sampling/handlers/openai.py +4 -169
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +38 -38
- fastmcp/resources/resource.py +33 -16
- fastmcp/resources/template.py +69 -59
- fastmcp/server/auth/__init__.py +0 -9
- fastmcp/server/auth/auth.py +127 -3
- fastmcp/server/auth/oauth_proxy.py +47 -97
- fastmcp/server/auth/oidc_proxy.py +7 -0
- fastmcp/server/auth/providers/in_memory.py +2 -2
- fastmcp/server/auth/providers/oci.py +2 -2
- fastmcp/server/context.py +509 -180
- fastmcp/server/dependencies.py +464 -6
- fastmcp/server/elicitation.py +285 -47
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +15 -3
- fastmcp/server/low_level.py +56 -12
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +4 -3
- fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +53 -40
- fastmcp/server/sampling/__init__.py +10 -0
- fastmcp/server/sampling/run.py +301 -0
- fastmcp/server/sampling/sampling_tool.py +108 -0
- fastmcp/server/server.py +793 -552
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +206 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +101 -103
- fastmcp/tools/tool.py +83 -49
- fastmcp/tools/tool_transform.py +1 -12
- fastmcp/utilities/components.py +3 -3
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
- fastmcp/utilities/tests.py +11 -5
- fastmcp/utilities/types.py +8 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
- fastmcp/client/sampling.py +0 -56
- fastmcp/experimental/sampling/handlers/base.py +0 -21
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- fastmcp/server/sampling/handler.py +0 -19
- fastmcp/utilities/openapi.py +0 -1568
- /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/auth/auth.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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",
|
|
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",
|
|
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
|
|
1199
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
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(
|