fastmcp 2.13.2__py3-none-any.whl → 2.14.0__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 +665 -129
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +37 -5
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +1 -1
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +33 -33
- fastmcp/resources/resource.py +29 -12
- fastmcp/resources/template.py +64 -54
- 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 +66 -72
- 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 +50 -37
- fastmcp/server/server.py +731 -532
- 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 +205 -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 +80 -44
- 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.2.dist-info → fastmcp-2.14.0.dist-info}/METADATA +5 -4
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/RECORD +71 -59
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- 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.2.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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(
|
fastmcp/server/context.py
CHANGED
|
@@ -3,15 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
import copy
|
|
4
4
|
import inspect
|
|
5
5
|
import logging
|
|
6
|
-
import warnings
|
|
7
6
|
import weakref
|
|
8
7
|
from collections.abc import Generator, Mapping, Sequence
|
|
9
8
|
from contextlib import contextmanager
|
|
10
9
|
from contextvars import ContextVar, Token
|
|
11
10
|
from dataclasses import dataclass
|
|
12
|
-
from enum import Enum
|
|
13
11
|
from logging import Logger
|
|
14
|
-
from typing import Any,
|
|
12
|
+
from typing import Any, overload
|
|
15
13
|
|
|
16
14
|
import anyio
|
|
17
15
|
from mcp import LoggingLevel, ServerSession
|
|
@@ -19,17 +17,16 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
19
17
|
from mcp.server.lowlevel.server import request_ctx
|
|
20
18
|
from mcp.shared.context import RequestContext
|
|
21
19
|
from mcp.types import (
|
|
22
|
-
AudioContent,
|
|
23
20
|
ClientCapabilities,
|
|
24
21
|
CreateMessageResult,
|
|
25
22
|
GetPromptResult,
|
|
26
|
-
ImageContent,
|
|
27
23
|
IncludeContext,
|
|
28
24
|
ModelHint,
|
|
29
25
|
ModelPreferences,
|
|
30
26
|
Root,
|
|
31
27
|
SamplingCapability,
|
|
32
28
|
SamplingMessage,
|
|
29
|
+
SamplingMessageContentBlock,
|
|
33
30
|
TextContent,
|
|
34
31
|
)
|
|
35
32
|
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
@@ -39,18 +36,15 @@ from pydantic.networks import AnyUrl
|
|
|
39
36
|
from starlette.requests import Request
|
|
40
37
|
from typing_extensions import TypeVar
|
|
41
38
|
|
|
42
|
-
import fastmcp.server.dependencies
|
|
43
|
-
from fastmcp import settings
|
|
44
39
|
from fastmcp.server.elicitation import (
|
|
45
40
|
AcceptedElicitation,
|
|
46
41
|
CancelledElicitation,
|
|
47
42
|
DeclinedElicitation,
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
handle_elicit_accept,
|
|
44
|
+
parse_elicit_response_type,
|
|
50
45
|
)
|
|
51
46
|
from fastmcp.server.server import FastMCP
|
|
52
47
|
from fastmcp.utilities.logging import _clamp_logger, get_logger
|
|
53
|
-
from fastmcp.utilities.types import get_cached_typeadapter
|
|
54
48
|
|
|
55
49
|
logger: Logger = get_logger(name=__name__)
|
|
56
50
|
to_client_logger: Logger = logger.getChild(suffix="to_client")
|
|
@@ -62,6 +56,7 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
|
|
|
62
56
|
|
|
63
57
|
|
|
64
58
|
T = TypeVar("T", default=Any)
|
|
59
|
+
|
|
65
60
|
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
|
|
66
61
|
_flush_lock = anyio.Lock()
|
|
67
62
|
|
|
@@ -169,6 +164,12 @@ class Context:
|
|
|
169
164
|
# Always set this context and save the token
|
|
170
165
|
token = _current_context.set(self)
|
|
171
166
|
self._tokens.append(token)
|
|
167
|
+
|
|
168
|
+
# Set current server for dependency injection (use weakref to avoid reference cycles)
|
|
169
|
+
from fastmcp.server.dependencies import _current_server
|
|
170
|
+
|
|
171
|
+
self._server_token = _current_server.set(weakref.ref(self.fastmcp))
|
|
172
|
+
|
|
172
173
|
return self
|
|
173
174
|
|
|
174
175
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
@@ -176,6 +177,14 @@ class Context:
|
|
|
176
177
|
# Flush any remaining notifications before exiting
|
|
177
178
|
await self._flush_notifications()
|
|
178
179
|
|
|
180
|
+
# Reset server token
|
|
181
|
+
if hasattr(self, "_server_token"):
|
|
182
|
+
from fastmcp.server.dependencies import _current_server
|
|
183
|
+
|
|
184
|
+
_current_server.reset(self._server_token)
|
|
185
|
+
delattr(self, "_server_token")
|
|
186
|
+
|
|
187
|
+
# Reset context token
|
|
179
188
|
if self._tokens:
|
|
180
189
|
token = self._tokens.pop()
|
|
181
190
|
_current_context.reset(token)
|
|
@@ -275,7 +284,8 @@ class Context:
|
|
|
275
284
|
Returns:
|
|
276
285
|
The resource content as either text or bytes
|
|
277
286
|
"""
|
|
278
|
-
|
|
287
|
+
# Context calls don't have task metadata, so always returns list
|
|
288
|
+
return await self.fastmcp._read_resource_mcp(uri) # type: ignore[return-value]
|
|
279
289
|
|
|
280
290
|
async def log(
|
|
281
291
|
self,
|
|
@@ -376,7 +386,7 @@ class Context:
|
|
|
376
386
|
session_id = str(uuid4())
|
|
377
387
|
|
|
378
388
|
# Save the session id to the session attributes
|
|
379
|
-
session._fastmcp_id = session_id
|
|
389
|
+
session._fastmcp_id = session_id # type: ignore[attr-defined]
|
|
380
390
|
return session_id
|
|
381
391
|
|
|
382
392
|
@property
|
|
@@ -474,6 +484,45 @@ class Context:
|
|
|
474
484
|
"""Send a prompt list changed notification to the client."""
|
|
475
485
|
await self.session.send_prompt_list_changed()
|
|
476
486
|
|
|
487
|
+
async def close_sse_stream(self) -> None:
|
|
488
|
+
"""Close the current response stream to trigger client reconnection.
|
|
489
|
+
|
|
490
|
+
When using StreamableHTTP transport with an EventStore configured, this
|
|
491
|
+
method gracefully closes the HTTP connection for the current request.
|
|
492
|
+
The client will automatically reconnect (after `retry_interval` milliseconds)
|
|
493
|
+
and resume receiving events from where it left off via the EventStore.
|
|
494
|
+
|
|
495
|
+
This is useful for long-running operations to avoid load balancer timeouts.
|
|
496
|
+
Instead of holding a connection open for minutes, you can periodically close
|
|
497
|
+
and let the client reconnect.
|
|
498
|
+
|
|
499
|
+
Example:
|
|
500
|
+
```python
|
|
501
|
+
@mcp.tool
|
|
502
|
+
async def long_running_task(ctx: Context) -> str:
|
|
503
|
+
for i in range(100):
|
|
504
|
+
await ctx.report_progress(i, 100)
|
|
505
|
+
|
|
506
|
+
# Close connection every 30 iterations to avoid LB timeouts
|
|
507
|
+
if i % 30 == 0 and i > 0:
|
|
508
|
+
await ctx.close_sse_stream()
|
|
509
|
+
|
|
510
|
+
await do_work()
|
|
511
|
+
return "Done"
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Note:
|
|
515
|
+
This is a no-op (with a debug log) if not using StreamableHTTP
|
|
516
|
+
transport with an EventStore configured.
|
|
517
|
+
"""
|
|
518
|
+
if not self.request_context or not self.request_context.close_sse_stream:
|
|
519
|
+
logger.debug(
|
|
520
|
+
"close_sse_stream() called but not applicable "
|
|
521
|
+
"(requires StreamableHTTP transport with event_store)"
|
|
522
|
+
)
|
|
523
|
+
return
|
|
524
|
+
await self.request_context.close_sse_stream()
|
|
525
|
+
|
|
477
526
|
async def sample(
|
|
478
527
|
self,
|
|
479
528
|
messages: str | Sequence[str | SamplingMessage],
|
|
@@ -482,7 +531,7 @@ class Context:
|
|
|
482
531
|
temperature: float | None = None,
|
|
483
532
|
max_tokens: int | None = None,
|
|
484
533
|
model_preferences: ModelPreferences | str | list[str] | None = None,
|
|
485
|
-
) ->
|
|
534
|
+
) -> SamplingMessageContentBlock | list[SamplingMessageContentBlock]:
|
|
486
535
|
"""
|
|
487
536
|
Send a sampling request to the client and await the response.
|
|
488
537
|
|
|
@@ -528,7 +577,7 @@ class Context:
|
|
|
528
577
|
maxTokens=max_tokens,
|
|
529
578
|
modelPreferences=_parse_model_preferences(model_preferences),
|
|
530
579
|
),
|
|
531
|
-
self.request_context,
|
|
580
|
+
self.request_context, # type: ignore[arg-type]
|
|
532
581
|
)
|
|
533
582
|
|
|
534
583
|
if inspect.isawaitable(create_message_result):
|
|
@@ -623,78 +672,23 @@ class Context:
|
|
|
623
672
|
type or dataclass or BaseModel. If it is a primitive type, an
|
|
624
673
|
object schema with a single "value" field will be generated.
|
|
625
674
|
"""
|
|
626
|
-
|
|
627
|
-
schema = {"type": "object", "properties": {}}
|
|
628
|
-
else:
|
|
629
|
-
# if the user provided a list of strings, treat it as a Literal
|
|
630
|
-
if isinstance(response_type, list):
|
|
631
|
-
if not all(isinstance(item, str) for item in response_type):
|
|
632
|
-
raise ValueError(
|
|
633
|
-
"List of options must be a list of strings. Received: "
|
|
634
|
-
f"{response_type}"
|
|
635
|
-
)
|
|
636
|
-
# Convert list of options to Literal type and wrap
|
|
637
|
-
choice_literal = Literal[tuple(response_type)] # type: ignore
|
|
638
|
-
response_type = ScalarElicitationType[choice_literal] # type: ignore
|
|
639
|
-
# if the user provided a primitive scalar, wrap it in an object schema
|
|
640
|
-
elif (
|
|
641
|
-
response_type in {bool, int, float, str}
|
|
642
|
-
or get_origin(response_type) is Literal
|
|
643
|
-
or (isinstance(response_type, type) and issubclass(response_type, Enum))
|
|
644
|
-
):
|
|
645
|
-
response_type = ScalarElicitationType[response_type] # type: ignore
|
|
646
|
-
|
|
647
|
-
response_type = cast(type[T], response_type)
|
|
648
|
-
|
|
649
|
-
schema = get_elicitation_schema(response_type)
|
|
675
|
+
config = parse_elicit_response_type(response_type)
|
|
650
676
|
|
|
651
677
|
result = await self.session.elicit(
|
|
652
678
|
message=message,
|
|
653
|
-
requestedSchema=schema,
|
|
679
|
+
requestedSchema=config.schema,
|
|
654
680
|
related_request_id=self.request_id,
|
|
655
681
|
)
|
|
656
682
|
|
|
657
683
|
if result.action == "accept":
|
|
658
|
-
|
|
659
|
-
type_adapter = get_cached_typeadapter(response_type)
|
|
660
|
-
validated_data = cast(
|
|
661
|
-
T | ScalarElicitationType[T],
|
|
662
|
-
type_adapter.validate_python(result.content),
|
|
663
|
-
)
|
|
664
|
-
if isinstance(validated_data, ScalarElicitationType):
|
|
665
|
-
return AcceptedElicitation[T](data=validated_data.value)
|
|
666
|
-
else:
|
|
667
|
-
return AcceptedElicitation[T](data=cast(T, validated_data))
|
|
668
|
-
elif result.content:
|
|
669
|
-
raise ValueError(
|
|
670
|
-
"Elicitation expected an empty response, but received: "
|
|
671
|
-
f"{result.content}"
|
|
672
|
-
)
|
|
673
|
-
else:
|
|
674
|
-
return AcceptedElicitation[dict[str, Any]](data={})
|
|
684
|
+
return handle_elicit_accept(config, result.content)
|
|
675
685
|
elif result.action == "decline":
|
|
676
686
|
return DeclinedElicitation()
|
|
677
687
|
elif result.action == "cancel":
|
|
678
688
|
return CancelledElicitation()
|
|
679
689
|
else:
|
|
680
|
-
# This should never happen, but handle it just in case
|
|
681
690
|
raise ValueError(f"Unexpected elicitation action: {result.action}")
|
|
682
691
|
|
|
683
|
-
def get_http_request(self) -> Request:
|
|
684
|
-
"""Get the active starlette request."""
|
|
685
|
-
|
|
686
|
-
# Deprecated in 2.2.11
|
|
687
|
-
if settings.deprecation_warnings:
|
|
688
|
-
warnings.warn(
|
|
689
|
-
"Context.get_http_request() is deprecated and will be removed in a future version. "
|
|
690
|
-
"Use get_http_request() from fastmcp.server.dependencies instead. "
|
|
691
|
-
"See https://gofastmcp.com/servers/context#http-requests for more details.",
|
|
692
|
-
DeprecationWarning,
|
|
693
|
-
stacklevel=2,
|
|
694
|
-
)
|
|
695
|
-
|
|
696
|
-
return fastmcp.server.dependencies.get_http_request()
|
|
697
|
-
|
|
698
692
|
def set_state(self, key: str, value: Any) -> None:
|
|
699
693
|
"""Set a value in the context state."""
|
|
700
694
|
self._state[key] = value
|