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.
- fastmcp/cli/cli.py +3 -4
- fastmcp/cli/install/cursor.py +12 -6
- fastmcp/client/auth/oauth.py +11 -6
- fastmcp/client/client.py +86 -20
- fastmcp/client/transports.py +4 -4
- fastmcp/experimental/utilities/openapi/director.py +13 -14
- fastmcp/experimental/utilities/openapi/parser.py +18 -15
- fastmcp/mcp_config.py +1 -1
- fastmcp/resources/resource_manager.py +3 -3
- fastmcp/server/auth/__init__.py +4 -0
- fastmcp/server/auth/auth.py +28 -9
- fastmcp/server/auth/handlers/authorize.py +7 -5
- fastmcp/server/auth/oauth_proxy.py +170 -30
- fastmcp/server/auth/oidc_proxy.py +28 -9
- fastmcp/server/auth/providers/azure.py +26 -5
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +1 -1
- fastmcp/server/auth/providers/in_memory.py +25 -1
- fastmcp/server/auth/providers/jwt.py +38 -26
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/supabase.py +21 -5
- fastmcp/server/auth/providers/workos.py +1 -1
- fastmcp/server/context.py +50 -8
- fastmcp/server/dependencies.py +8 -2
- fastmcp/server/middleware/caching.py +9 -2
- fastmcp/server/middleware/logging.py +2 -2
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/proxy.py +1 -1
- fastmcp/server/server.py +11 -5
- fastmcp/tools/tool.py +33 -8
- fastmcp/utilities/components.py +2 -2
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/logging.py +13 -9
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/utilities/openapi.py +2 -2
- fastmcp/utilities/types.py +28 -15
- fastmcp/utilities/ui.py +1 -1
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/METADATA +14 -11
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/RECORD +42 -40
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -102,7 +102,7 @@ class DescopeProvider(RemoteAuthProvider):
|
|
|
102
102
|
)
|
|
103
103
|
|
|
104
104
|
self.project_id = settings.project_id
|
|
105
|
-
self.base_url = str(settings.base_url).rstrip("/")
|
|
105
|
+
self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
|
|
106
106
|
self.descope_base_url = str(settings.descope_base_url).rstrip("/")
|
|
107
107
|
|
|
108
108
|
# Create default JWT verifier if none provided
|
|
@@ -66,6 +66,22 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|
|
66
66
|
return self.clients.get(client_id)
|
|
67
67
|
|
|
68
68
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
69
|
+
# Validate scopes against valid_scopes if configured (matches MCP SDK behavior)
|
|
70
|
+
if (
|
|
71
|
+
client_info.scope is not None
|
|
72
|
+
and self.client_registration_options is not None
|
|
73
|
+
and self.client_registration_options.valid_scopes is not None
|
|
74
|
+
):
|
|
75
|
+
requested_scopes = set(client_info.scope.split())
|
|
76
|
+
valid_scopes = set(self.client_registration_options.valid_scopes)
|
|
77
|
+
invalid_scopes = requested_scopes - valid_scopes
|
|
78
|
+
if invalid_scopes:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Requested scopes are not valid: {', '.join(invalid_scopes)}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if client_info.client_id is None:
|
|
84
|
+
raise ValueError("client_id is required for client registration")
|
|
69
85
|
if client_info.client_id in self.clients:
|
|
70
86
|
# As per RFC 7591, if client_id is already known, it's an update.
|
|
71
87
|
# For this simple provider, we'll treat it as re-registration.
|
|
@@ -91,7 +107,7 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|
|
91
107
|
# OAuthClientInformationFull should have a method like validate_redirect_uri
|
|
92
108
|
# For this test provider, we assume it's valid if it matches one in client_info
|
|
93
109
|
# The AuthorizationHandler already does robust validation using client.validate_redirect_uri
|
|
94
|
-
if params.redirect_uri not in client.redirect_uris:
|
|
110
|
+
if client.redirect_uris and params.redirect_uri not in client.redirect_uris:
|
|
95
111
|
# This check might be too simplistic if redirect_uris can be patterns
|
|
96
112
|
# or if params.redirect_uri is None and client has a default.
|
|
97
113
|
# However, the AuthorizationHandler handles the primary validation.
|
|
@@ -110,6 +126,10 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|
|
110
126
|
client_allowed_scopes = set(client.scope.split())
|
|
111
127
|
scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
|
|
112
128
|
|
|
129
|
+
if client.client_id is None:
|
|
130
|
+
raise AuthorizeError(
|
|
131
|
+
error="invalid_client", error_description="Client ID is required"
|
|
132
|
+
)
|
|
113
133
|
auth_code = AuthorizationCode(
|
|
114
134
|
code=auth_code_value,
|
|
115
135
|
client_id=client.client_id,
|
|
@@ -166,6 +186,8 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|
|
166
186
|
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
|
167
187
|
)
|
|
168
188
|
|
|
189
|
+
if client.client_id is None:
|
|
190
|
+
raise TokenError("invalid_client", "Client ID is required")
|
|
169
191
|
self.access_tokens[access_token_value] = AccessToken(
|
|
170
192
|
token=access_token_value,
|
|
171
193
|
client_id=client.client_id,
|
|
@@ -236,6 +258,8 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|
|
236
258
|
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
|
237
259
|
)
|
|
238
260
|
|
|
261
|
+
if client.client_id is None:
|
|
262
|
+
raise TokenError("invalid_client", "Client ID is required")
|
|
239
263
|
self.access_tokens[new_access_token_value] = AccessToken(
|
|
240
264
|
token=new_access_token_value,
|
|
241
265
|
client_id=client.client_id,
|
|
@@ -150,7 +150,7 @@ class JWTVerifierSettings(BaseSettings):
|
|
|
150
150
|
|
|
151
151
|
public_key: str | None = None
|
|
152
152
|
jwks_uri: str | None = None
|
|
153
|
-
issuer: str | None = None
|
|
153
|
+
issuer: str | list[str] | None = None
|
|
154
154
|
algorithm: str | None = None
|
|
155
155
|
audience: str | list[str] | None = None
|
|
156
156
|
required_scopes: list[str] | None = None
|
|
@@ -186,26 +186,26 @@ class JWTVerifier(TokenVerifier):
|
|
|
186
186
|
*,
|
|
187
187
|
public_key: str | NotSetT | None = NotSet,
|
|
188
188
|
jwks_uri: str | NotSetT | None = NotSet,
|
|
189
|
-
issuer: str | NotSetT | None = NotSet,
|
|
189
|
+
issuer: str | list[str] | NotSetT | None = NotSet,
|
|
190
190
|
audience: str | list[str] | NotSetT | None = NotSet,
|
|
191
191
|
algorithm: str | NotSetT | None = NotSet,
|
|
192
192
|
required_scopes: list[str] | NotSetT | None = NotSet,
|
|
193
193
|
base_url: AnyHttpUrl | str | NotSetT | None = NotSet,
|
|
194
194
|
):
|
|
195
195
|
"""
|
|
196
|
-
Initialize
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
public_key
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
196
|
+
Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint.
|
|
197
|
+
|
|
198
|
+
Parameters:
|
|
199
|
+
public_key (str | NotSetT | None): PEM-encoded public key for asymmetric algorithms or shared secret for symmetric algorithms.
|
|
200
|
+
jwks_uri (str | NotSetT | None): URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS.
|
|
201
|
+
issuer (str | list[str] | NotSetT | None): Expected issuer claim value or list of allowed issuer values.
|
|
202
|
+
audience (str | list[str] | NotSetT | None): Expected audience claim value or list of allowed audience values.
|
|
203
|
+
algorithm (str | NotSetT | None): JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512.
|
|
204
|
+
required_scopes (list[str] | NotSetT | None): Scopes that must be present in validated tokens.
|
|
205
|
+
base_url (AnyHttpUrl | str | NotSetT | None): Base URL passed to the parent TokenVerifier.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported.
|
|
209
209
|
"""
|
|
210
210
|
settings = JWTVerifierSettings.model_validate(
|
|
211
211
|
{
|
|
@@ -366,13 +366,13 @@ class JWTVerifier(TokenVerifier):
|
|
|
366
366
|
|
|
367
367
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
368
368
|
"""
|
|
369
|
-
|
|
369
|
+
Validate a JWT bearer token and return an AccessToken when the token is valid.
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
token: The JWT token string to validate
|
|
371
|
+
Parameters:
|
|
372
|
+
token (str): The JWT bearer token string to validate.
|
|
373
373
|
|
|
374
374
|
Returns:
|
|
375
|
-
AccessToken
|
|
375
|
+
AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs.
|
|
376
376
|
"""
|
|
377
377
|
try:
|
|
378
378
|
# Get verification key (static or from JWKS)
|
|
@@ -400,13 +400,25 @@ class JWTVerifier(TokenVerifier):
|
|
|
400
400
|
|
|
401
401
|
# Validate issuer - note we use issuer instead of issuer_url here because
|
|
402
402
|
# issuer is optional, allowing users to make this check optional
|
|
403
|
-
if self.issuer
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
self.
|
|
409
|
-
|
|
403
|
+
if self.issuer:
|
|
404
|
+
iss = claims.get("iss")
|
|
405
|
+
|
|
406
|
+
# Handle different combinations of issuer types
|
|
407
|
+
issuer_valid = False
|
|
408
|
+
if isinstance(self.issuer, list):
|
|
409
|
+
# self.issuer is a list - check if token issuer matches any expected issuer
|
|
410
|
+
issuer_valid = iss in self.issuer
|
|
411
|
+
else:
|
|
412
|
+
# self.issuer is a string - check for equality
|
|
413
|
+
issuer_valid = iss == self.issuer
|
|
414
|
+
|
|
415
|
+
if not issuer_valid:
|
|
416
|
+
self.logger.debug(
|
|
417
|
+
"Token validation failed: issuer mismatch for client %s",
|
|
418
|
+
client_id,
|
|
419
|
+
)
|
|
420
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
421
|
+
return None
|
|
410
422
|
|
|
411
423
|
# Validate audience if configured
|
|
412
424
|
if self.audience:
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""OCI OIDC provider for FastMCP.
|
|
2
|
+
|
|
3
|
+
The pull request for the provider is submitted to fastmcp.
|
|
4
|
+
|
|
5
|
+
This module provides OIDC Implementation to integrate MCP servers with OCI.
|
|
6
|
+
You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL.
|
|
7
|
+
|
|
8
|
+
Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane.
|
|
9
|
+
You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs.
|
|
10
|
+
The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object.
|
|
11
|
+
You can use the signer object to create OCI service object.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
```python
|
|
15
|
+
from fastmcp import FastMCP
|
|
16
|
+
from fastmcp.server.auth.providers.oci import OCIProvider
|
|
17
|
+
from fastmcp.server.dependencies import get_access_token
|
|
18
|
+
from fastmcp.utilities.logging import get_logger
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
# Load configuration from environment
|
|
23
|
+
FASTMCP_SERVER_AUTH_OCI_CONFIG_URL = os.environ["FASTMCP_SERVER_AUTH_OCI_CONFIG_URL"]
|
|
24
|
+
FASTMCP_SERVER_AUTH_OCI_CLIENT_ID = os.environ["FASTMCP_SERVER_AUTH_OCI_CLIENT_ID"]
|
|
25
|
+
FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET = os.environ["FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET"]
|
|
26
|
+
FASTMCP_SERVER_AUTH_OCI_IAM_GUID = os.environ["FASTMCP_SERVER_AUTH_OCI_IAM_GUID"]
|
|
27
|
+
|
|
28
|
+
import oci
|
|
29
|
+
from oci.auth.signers import TokenExchangeSigner
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
# Simple OCI OIDC protection
|
|
34
|
+
auth = OCIProvider(
|
|
35
|
+
config_url=FASTMCP_SERVER_AUTH_OCI_CONFIG_URL, #config URL is the OCI IAM Domain OIDC discovery URL.
|
|
36
|
+
client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
|
37
|
+
client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET, #This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
|
38
|
+
required_scopes=["openid", "profile", "email"],
|
|
39
|
+
redirect_path="/auth/callback",
|
|
40
|
+
base_url="http://localhost:8000",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# NOTE: For production use, replace this with a thread-safe cache implementation
|
|
44
|
+
# such as threading.Lock-protected dict or a proper caching library
|
|
45
|
+
_global_token_cache = {} #In memory cache for OCI session token signer
|
|
46
|
+
|
|
47
|
+
def get_oci_signer() -> TokenExchangeSigner:
|
|
48
|
+
|
|
49
|
+
authntoken = get_access_token()
|
|
50
|
+
tokenID = authntoken.claims.get("jti")
|
|
51
|
+
token = authntoken.token
|
|
52
|
+
|
|
53
|
+
#Check if the signer exists for the token ID in memory cache
|
|
54
|
+
cached_signer = _global_token_cache.get(tokenID)
|
|
55
|
+
logger.debug(f"Global cached signer: {cached_signer}")
|
|
56
|
+
if cached_signer:
|
|
57
|
+
logger.debug(f"Using globally cached signer for token ID: {tokenID}")
|
|
58
|
+
return cached_signer
|
|
59
|
+
|
|
60
|
+
#If the signer is not yet created for the token then create new OCI signer object
|
|
61
|
+
logger.debug(f"Creating new signer for token ID: {tokenID}")
|
|
62
|
+
signer = TokenExchangeSigner(
|
|
63
|
+
jwt_or_func=token,
|
|
64
|
+
oci_domain_id=FASTMCP_SERVER_AUTH_OCI_IAM_GUID.split(".")[0], #This is same as IAM GUID configured for the OCI IAM Domain
|
|
65
|
+
client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
|
66
|
+
client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET #This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
|
67
|
+
)
|
|
68
|
+
logger.debug(f"Signer {signer} created for token ID: {tokenID}")
|
|
69
|
+
|
|
70
|
+
#Cache the signer object in memory cache
|
|
71
|
+
_global_token_cache[tokenID] = signer
|
|
72
|
+
logger.debug(f"Signer cached for token ID: {tokenID}")
|
|
73
|
+
|
|
74
|
+
return signer
|
|
75
|
+
|
|
76
|
+
mcp = FastMCP("My Protected Server", auth=auth)
|
|
77
|
+
```
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
from key_value.aio.protocols import AsyncKeyValue
|
|
81
|
+
from pydantic import AnyHttpUrl, SecretStr, field_validator
|
|
82
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
83
|
+
|
|
84
|
+
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
|
85
|
+
from fastmcp.settings import ENV_FILE
|
|
86
|
+
from fastmcp.utilities.auth import parse_scopes
|
|
87
|
+
from fastmcp.utilities.logging import get_logger
|
|
88
|
+
from fastmcp.utilities.types import NotSet, NotSetT
|
|
89
|
+
|
|
90
|
+
logger = get_logger(__name__)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class OCIProviderSettings(BaseSettings):
|
|
94
|
+
"""Settings for OCI IAM domain OIDC provider."""
|
|
95
|
+
|
|
96
|
+
model_config = SettingsConfigDict(
|
|
97
|
+
env_prefix="FASTMCP_SERVER_AUTH_OCI_",
|
|
98
|
+
env_file=ENV_FILE,
|
|
99
|
+
extra="ignore",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
config_url: AnyHttpUrl | None = None
|
|
103
|
+
client_id: str | None = None
|
|
104
|
+
client_secret: SecretStr | None = None
|
|
105
|
+
audience: str | None = None
|
|
106
|
+
base_url: AnyHttpUrl | None = None
|
|
107
|
+
issuer_url: AnyHttpUrl | None = None
|
|
108
|
+
redirect_path: str | None = None
|
|
109
|
+
required_scopes: list[str] | None = None
|
|
110
|
+
allowed_client_redirect_uris: list[str] | None = None
|
|
111
|
+
jwt_signing_key: str | bytes | None = None
|
|
112
|
+
|
|
113
|
+
@field_validator("required_scopes", mode="before")
|
|
114
|
+
@classmethod
|
|
115
|
+
def _parse_scopes(cls, v):
|
|
116
|
+
return parse_scopes(v)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class OCIProvider(OIDCProxy):
|
|
120
|
+
"""An OCI IAM Domain provider implementation for FastMCP.
|
|
121
|
+
|
|
122
|
+
This provider is a complete OCI integration that's ready to use with
|
|
123
|
+
just the configuration URL, client ID, client secret, and base URL.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
```python
|
|
127
|
+
from fastmcp import FastMCP
|
|
128
|
+
from fastmcp.server.auth.providers.oci import OCIProvider
|
|
129
|
+
|
|
130
|
+
# Simple OCI OIDC protection
|
|
131
|
+
auth = OCIProvider(
|
|
132
|
+
config_url=FASTMCP_SERVER_AUTH_OCI_CONFIG_URL, #config URL is the OCI IAM Domain OIDC discovery URL.
|
|
133
|
+
client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
|
134
|
+
client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET, #This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
|
135
|
+
base_url="http://localhost:8000",
|
|
136
|
+
required_scopes=["openid", "profile", "email"],
|
|
137
|
+
redirect_path="/auth/callback",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
mcp = FastMCP("My Protected Server", auth=auth)
|
|
141
|
+
```
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
config_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
148
|
+
client_id: str | NotSetT = NotSet,
|
|
149
|
+
client_secret: str | NotSetT = NotSet,
|
|
150
|
+
audience: str | NotSetT = NotSet,
|
|
151
|
+
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
152
|
+
issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
153
|
+
required_scopes: list[str] | NotSetT = NotSet,
|
|
154
|
+
redirect_path: str | NotSetT = NotSet,
|
|
155
|
+
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
156
|
+
client_storage: AsyncKeyValue | None = None,
|
|
157
|
+
jwt_signing_key: str | bytes | NotSetT = NotSet,
|
|
158
|
+
require_authorization_consent: bool = True,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Initialize OCI OIDC provider.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
config_url: OCI OIDC Discovery URL
|
|
164
|
+
client_id: OCI IAM Domain Integrated Application client id
|
|
165
|
+
client_secret: OCI Integrated Application client secret
|
|
166
|
+
audience: OCI API audience (optional)
|
|
167
|
+
base_url: Public URL where OIDC endpoints will be accessible (includes any mount path)
|
|
168
|
+
issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL.
|
|
169
|
+
required_scopes: Required OCI scopes (defaults to ["openid"])
|
|
170
|
+
redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback".
|
|
171
|
+
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
overrides = {
|
|
175
|
+
k: v
|
|
176
|
+
for k, v in {
|
|
177
|
+
"config_url": config_url,
|
|
178
|
+
"client_id": client_id,
|
|
179
|
+
"client_secret": client_secret,
|
|
180
|
+
"audience": audience,
|
|
181
|
+
"base_url": base_url,
|
|
182
|
+
"issuer_url": issuer_url,
|
|
183
|
+
"required_scopes": required_scopes,
|
|
184
|
+
"redirect_path": redirect_path,
|
|
185
|
+
"allowed_client_redirect_uris": allowed_client_redirect_uris,
|
|
186
|
+
"jwt_signing_key": jwt_signing_key,
|
|
187
|
+
}.items()
|
|
188
|
+
if v is not NotSet
|
|
189
|
+
}
|
|
190
|
+
settings = OCIProviderSettings(**overrides)
|
|
191
|
+
|
|
192
|
+
if not settings.config_url:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"config_url is required - set via parameter or FASTMCP_SERVER_AUTH_OCI_CONFIG_URL"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if not settings.client_id:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
"client_id is required - set via parameter or FASTMCP_SERVER_AUTH_OCI_CLIENT_ID"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not settings.client_secret:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
"client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not settings.base_url:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
"base_url is required - set via parameter or FASTMCP_SERVER_AUTH_OCI_BASE_URL"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
oci_required_scopes = settings.required_scopes or ["openid"]
|
|
213
|
+
|
|
214
|
+
super().__init__(
|
|
215
|
+
config_url=settings.config_url,
|
|
216
|
+
client_id=settings.client_id,
|
|
217
|
+
client_secret=settings.client_secret.get_secret_value(),
|
|
218
|
+
audience=settings.audience,
|
|
219
|
+
base_url=settings.base_url,
|
|
220
|
+
issuer_url=settings.issuer_url,
|
|
221
|
+
redirect_path=settings.redirect_path,
|
|
222
|
+
required_scopes=oci_required_scopes,
|
|
223
|
+
allowed_client_redirect_uris=settings.allowed_client_redirect_uris,
|
|
224
|
+
client_storage=client_storage,
|
|
225
|
+
jwt_signing_key=settings.jwt_signing_key,
|
|
226
|
+
require_authorization_consent=require_authorization_consent,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
logger.debug(
|
|
230
|
+
"Initialized OCI OAuth provider for client %s with scopes: %s",
|
|
231
|
+
settings.client_id,
|
|
232
|
+
oci_required_scopes,
|
|
233
|
+
)
|
|
@@ -7,6 +7,8 @@ for seamless MCP client authentication.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
10
12
|
import httpx
|
|
11
13
|
from pydantic import AnyHttpUrl, field_validator
|
|
12
14
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
@@ -32,6 +34,7 @@ class SupabaseProviderSettings(BaseSettings):
|
|
|
32
34
|
|
|
33
35
|
project_url: AnyHttpUrl
|
|
34
36
|
base_url: AnyHttpUrl
|
|
37
|
+
algorithm: Literal["HS256", "RS256", "ES256"] = "ES256"
|
|
35
38
|
required_scopes: list[str] | None = None
|
|
36
39
|
|
|
37
40
|
@field_validator("required_scopes", mode="before")
|
|
@@ -52,13 +55,19 @@ class SupabaseProvider(RemoteAuthProvider):
|
|
|
52
55
|
1. Supabase Project Setup:
|
|
53
56
|
- Create a Supabase project at https://supabase.com
|
|
54
57
|
- Note your project URL (e.g., "https://abc123.supabase.co")
|
|
55
|
-
-
|
|
56
|
-
-
|
|
58
|
+
- Configure your JWT algorithm in Supabase Auth settings (HS256, RS256, or ES256)
|
|
59
|
+
- Asymmetric keys (RS256/ES256) are recommended for production
|
|
57
60
|
|
|
58
61
|
2. JWT Verification:
|
|
59
62
|
- FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
|
|
60
63
|
- JWTs are issued by {project_url}/auth/v1
|
|
61
64
|
- Tokens are cached for up to 10 minutes by Supabase's edge servers
|
|
65
|
+
- Algorithm must match your Supabase Auth configuration
|
|
66
|
+
|
|
67
|
+
3. Authorization:
|
|
68
|
+
- Supabase uses Row Level Security (RLS) policies for database authorization
|
|
69
|
+
- OAuth-level scopes are an upcoming feature in Supabase Auth
|
|
70
|
+
- Both approaches will be supported once scope handling is available
|
|
62
71
|
|
|
63
72
|
For detailed setup instructions, see:
|
|
64
73
|
https://supabase.com/docs/guides/auth/jwts
|
|
@@ -71,6 +80,7 @@ class SupabaseProvider(RemoteAuthProvider):
|
|
|
71
80
|
supabase_auth = SupabaseProvider(
|
|
72
81
|
project_url="https://abc123.supabase.co",
|
|
73
82
|
base_url="https://your-fastmcp-server.com",
|
|
83
|
+
algorithm="ES256", # Match your Supabase Auth configuration
|
|
74
84
|
)
|
|
75
85
|
|
|
76
86
|
# Use with FastMCP
|
|
@@ -83,6 +93,7 @@ class SupabaseProvider(RemoteAuthProvider):
|
|
|
83
93
|
*,
|
|
84
94
|
project_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
85
95
|
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
96
|
+
algorithm: Literal["HS256", "RS256", "ES256"] | NotSetT = NotSet,
|
|
86
97
|
required_scopes: list[str] | NotSetT | None = NotSet,
|
|
87
98
|
token_verifier: TokenVerifier | None = None,
|
|
88
99
|
):
|
|
@@ -91,7 +102,11 @@ class SupabaseProvider(RemoteAuthProvider):
|
|
|
91
102
|
Args:
|
|
92
103
|
project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
|
|
93
104
|
base_url: Public URL of this FastMCP server
|
|
94
|
-
|
|
105
|
+
algorithm: JWT signing algorithm (HS256, RS256, or ES256). Must match your
|
|
106
|
+
Supabase Auth configuration. Defaults to ES256.
|
|
107
|
+
required_scopes: Optional list of scopes to require for all requests.
|
|
108
|
+
Note: Supabase currently uses RLS policies for authorization. OAuth-level
|
|
109
|
+
scopes are an upcoming feature.
|
|
95
110
|
token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase
|
|
96
111
|
"""
|
|
97
112
|
settings = SupabaseProviderSettings.model_validate(
|
|
@@ -100,6 +115,7 @@ class SupabaseProvider(RemoteAuthProvider):
|
|
|
100
115
|
for k, v in {
|
|
101
116
|
"project_url": project_url,
|
|
102
117
|
"base_url": base_url,
|
|
118
|
+
"algorithm": algorithm,
|
|
103
119
|
"required_scopes": required_scopes,
|
|
104
120
|
}.items()
|
|
105
121
|
if v is not NotSet
|
|
@@ -107,14 +123,14 @@ class SupabaseProvider(RemoteAuthProvider):
|
|
|
107
123
|
)
|
|
108
124
|
|
|
109
125
|
self.project_url = str(settings.project_url).rstrip("/")
|
|
110
|
-
self.base_url = str(settings.base_url).rstrip("/")
|
|
126
|
+
self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
|
|
111
127
|
|
|
112
128
|
# Create default JWT verifier if none provided
|
|
113
129
|
if token_verifier is None:
|
|
114
130
|
token_verifier = JWTVerifier(
|
|
115
131
|
jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json",
|
|
116
132
|
issuer=f"{self.project_url}/auth/v1",
|
|
117
|
-
algorithm=
|
|
133
|
+
algorithm=settings.algorithm,
|
|
118
134
|
required_scopes=settings.required_scopes,
|
|
119
135
|
)
|
|
120
136
|
|
|
@@ -362,7 +362,7 @@ class AuthKitProvider(RemoteAuthProvider):
|
|
|
362
362
|
)
|
|
363
363
|
|
|
364
364
|
self.authkit_domain = str(settings.authkit_domain).rstrip("/")
|
|
365
|
-
self.base_url = str(settings.base_url).rstrip("/")
|
|
365
|
+
self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
|
|
366
366
|
|
|
367
367
|
# Create default JWT verifier if none provided
|
|
368
368
|
if token_verifier is None:
|
fastmcp/server/context.py
CHANGED
|
@@ -181,15 +181,33 @@ class Context:
|
|
|
181
181
|
_current_context.reset(token)
|
|
182
182
|
|
|
183
183
|
@property
|
|
184
|
-
def request_context(self) -> RequestContext[ServerSession, Any, Request]:
|
|
184
|
+
def request_context(self) -> RequestContext[ServerSession, Any, Request] | None:
|
|
185
185
|
"""Access to the underlying request context.
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
Returns None when the MCP session has not been established yet.
|
|
188
|
+
Returns the full RequestContext once the MCP session is available.
|
|
189
|
+
|
|
190
|
+
For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies,
|
|
191
|
+
which works whether or not the MCP session is available.
|
|
192
|
+
|
|
193
|
+
Example in middleware:
|
|
194
|
+
```python
|
|
195
|
+
async def on_request(self, context, call_next):
|
|
196
|
+
ctx = context.fastmcp_context
|
|
197
|
+
if ctx.request_context:
|
|
198
|
+
# MCP session available - can access session_id, request_id, etc.
|
|
199
|
+
session_id = ctx.session_id
|
|
200
|
+
else:
|
|
201
|
+
# MCP session not available yet - use HTTP helpers
|
|
202
|
+
from fastmcp.server.dependencies import get_http_request
|
|
203
|
+
request = get_http_request()
|
|
204
|
+
return await call_next(context)
|
|
205
|
+
```
|
|
188
206
|
"""
|
|
189
207
|
try:
|
|
190
208
|
return request_ctx.get()
|
|
191
|
-
except LookupError
|
|
192
|
-
|
|
209
|
+
except LookupError:
|
|
210
|
+
return None
|
|
193
211
|
|
|
194
212
|
async def report_progress(
|
|
195
213
|
self, progress: float, total: float | None = None, message: str | None = None
|
|
@@ -203,7 +221,7 @@ class Context:
|
|
|
203
221
|
|
|
204
222
|
progress_token = (
|
|
205
223
|
self.request_context.meta.progressToken
|
|
206
|
-
if self.request_context.meta
|
|
224
|
+
if self.request_context and self.request_context.meta
|
|
207
225
|
else None
|
|
208
226
|
)
|
|
209
227
|
|
|
@@ -292,13 +310,21 @@ class Context:
|
|
|
292
310
|
"""Get the client ID if available."""
|
|
293
311
|
return (
|
|
294
312
|
getattr(self.request_context.meta, "client_id", None)
|
|
295
|
-
if self.request_context.meta
|
|
313
|
+
if self.request_context and self.request_context.meta
|
|
296
314
|
else None
|
|
297
315
|
)
|
|
298
316
|
|
|
299
317
|
@property
|
|
300
318
|
def request_id(self) -> str:
|
|
301
|
-
"""Get the unique ID for this request.
|
|
319
|
+
"""Get the unique ID for this request.
|
|
320
|
+
|
|
321
|
+
Raises RuntimeError if MCP request context is not available.
|
|
322
|
+
"""
|
|
323
|
+
if self.request_context is None:
|
|
324
|
+
raise RuntimeError(
|
|
325
|
+
"request_id is not available because the MCP session has not been established yet. "
|
|
326
|
+
"Check `context.request_context` for None before accessing this attribute."
|
|
327
|
+
)
|
|
302
328
|
return str(self.request_context.request_id)
|
|
303
329
|
|
|
304
330
|
@property
|
|
@@ -313,6 +339,9 @@ class Context:
|
|
|
313
339
|
The session ID for StreamableHTTP transports, or a generated ID
|
|
314
340
|
for other transports.
|
|
315
341
|
|
|
342
|
+
Raises:
|
|
343
|
+
RuntimeError if MCP request context is not available.
|
|
344
|
+
|
|
316
345
|
Example:
|
|
317
346
|
```python
|
|
318
347
|
@server.tool
|
|
@@ -323,6 +352,11 @@ class Context:
|
|
|
323
352
|
```
|
|
324
353
|
"""
|
|
325
354
|
request_ctx = self.request_context
|
|
355
|
+
if request_ctx is None:
|
|
356
|
+
raise RuntimeError(
|
|
357
|
+
"session_id is not available because the MCP session has not been established yet. "
|
|
358
|
+
"Check `context.request_context` for None before accessing this attribute."
|
|
359
|
+
)
|
|
326
360
|
session = request_ctx.session
|
|
327
361
|
|
|
328
362
|
# Try to get the session ID from the session attributes
|
|
@@ -347,7 +381,15 @@ class Context:
|
|
|
347
381
|
|
|
348
382
|
@property
|
|
349
383
|
def session(self) -> ServerSession:
|
|
350
|
-
"""Access to the underlying session for advanced usage.
|
|
384
|
+
"""Access to the underlying session for advanced usage.
|
|
385
|
+
|
|
386
|
+
Raises RuntimeError if MCP request context is not available.
|
|
387
|
+
"""
|
|
388
|
+
if self.request_context is None:
|
|
389
|
+
raise RuntimeError(
|
|
390
|
+
"session is not available because the MCP session has not been established yet. "
|
|
391
|
+
"Check `context.request_context` for None before accessing this attribute."
|
|
392
|
+
)
|
|
351
393
|
return self.request_context.session
|
|
352
394
|
|
|
353
395
|
# Convenience methods for common log levels
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -9,9 +9,11 @@ from mcp.server.auth.middleware.auth_context import (
|
|
|
9
9
|
from mcp.server.auth.provider import (
|
|
10
10
|
AccessToken as _SDKAccessToken,
|
|
11
11
|
)
|
|
12
|
+
from mcp.server.lowlevel.server import request_ctx
|
|
12
13
|
from starlette.requests import Request
|
|
13
14
|
|
|
14
15
|
from fastmcp.server.auth import AccessToken
|
|
16
|
+
from fastmcp.server.http import _current_http_request
|
|
15
17
|
|
|
16
18
|
if TYPE_CHECKING:
|
|
17
19
|
from fastmcp.server.context import Context
|
|
@@ -41,12 +43,16 @@ def get_context() -> Context:
|
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def get_http_request() -> Request:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
# Try MCP SDK's request_ctx first (set during normal MCP request handling)
|
|
46
47
|
request = None
|
|
47
48
|
with contextlib.suppress(LookupError):
|
|
48
49
|
request = request_ctx.get().request
|
|
49
50
|
|
|
51
|
+
# Fallback to FastMCP's HTTP context variable
|
|
52
|
+
# This is needed during `on_initialize` middleware where request_ctx isn't set yet
|
|
53
|
+
if request is None:
|
|
54
|
+
request = _current_http_request.get()
|
|
55
|
+
|
|
50
56
|
if request is None:
|
|
51
57
|
raise RuntimeError("No active HTTP request found.")
|
|
52
58
|
return request
|
|
@@ -63,14 +63,21 @@ class CachableReadResourceContents(BaseModel):
|
|
|
63
63
|
class CachableToolResult(BaseModel):
|
|
64
64
|
content: list[mcp.types.ContentBlock]
|
|
65
65
|
structured_content: dict[str, Any] | None
|
|
66
|
+
meta: dict[str, Any] | None
|
|
66
67
|
|
|
67
68
|
@classmethod
|
|
68
69
|
def wrap(cls, value: ToolResult) -> Self:
|
|
69
|
-
return cls(
|
|
70
|
+
return cls(
|
|
71
|
+
content=value.content,
|
|
72
|
+
structured_content=value.structured_content,
|
|
73
|
+
meta=value.meta,
|
|
74
|
+
)
|
|
70
75
|
|
|
71
76
|
def unwrap(self) -> ToolResult:
|
|
72
77
|
return ToolResult(
|
|
73
|
-
content=self.content,
|
|
78
|
+
content=self.content,
|
|
79
|
+
structured_content=self.structured_content,
|
|
80
|
+
meta=self.meta,
|
|
74
81
|
)
|
|
75
82
|
|
|
76
83
|
|