fastmcp 2.13.0.2__py3-none-any.whl → 2.13.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. fastmcp/cli/cli.py +3 -4
  2. fastmcp/cli/install/cursor.py +12 -6
  3. fastmcp/client/auth/oauth.py +11 -6
  4. fastmcp/client/client.py +86 -20
  5. fastmcp/client/transports.py +4 -4
  6. fastmcp/experimental/utilities/openapi/director.py +13 -14
  7. fastmcp/experimental/utilities/openapi/parser.py +18 -15
  8. fastmcp/mcp_config.py +1 -1
  9. fastmcp/resources/resource_manager.py +3 -3
  10. fastmcp/server/auth/__init__.py +4 -0
  11. fastmcp/server/auth/auth.py +28 -9
  12. fastmcp/server/auth/handlers/authorize.py +7 -5
  13. fastmcp/server/auth/oauth_proxy.py +170 -30
  14. fastmcp/server/auth/oidc_proxy.py +28 -9
  15. fastmcp/server/auth/providers/azure.py +26 -5
  16. fastmcp/server/auth/providers/debug.py +114 -0
  17. fastmcp/server/auth/providers/descope.py +1 -1
  18. fastmcp/server/auth/providers/in_memory.py +25 -1
  19. fastmcp/server/auth/providers/jwt.py +38 -26
  20. fastmcp/server/auth/providers/oci.py +233 -0
  21. fastmcp/server/auth/providers/supabase.py +21 -5
  22. fastmcp/server/auth/providers/workos.py +1 -1
  23. fastmcp/server/context.py +50 -8
  24. fastmcp/server/dependencies.py +8 -2
  25. fastmcp/server/middleware/caching.py +9 -2
  26. fastmcp/server/middleware/logging.py +2 -2
  27. fastmcp/server/middleware/middleware.py +2 -2
  28. fastmcp/server/proxy.py +1 -1
  29. fastmcp/server/server.py +11 -5
  30. fastmcp/tools/tool.py +33 -8
  31. fastmcp/utilities/components.py +2 -2
  32. fastmcp/utilities/json_schema.py +4 -4
  33. fastmcp/utilities/logging.py +13 -9
  34. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  35. fastmcp/utilities/openapi.py +2 -2
  36. fastmcp/utilities/types.py +28 -15
  37. fastmcp/utilities/ui.py +1 -1
  38. {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/METADATA +12 -9
  39. {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/RECORD +42 -40
  40. {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/WHEEL +0 -0
  41. {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/entry_points.txt +0 -0
  42. {fastmcp-2.13.0.2.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 the JWT token verifier.
197
-
198
- Args:
199
- public_key: For asymmetric algorithms (RS256, ES256, etc.): PEM-encoded public key.
200
- For symmetric algorithms (HS256, HS384, HS512): The shared secret string.
201
- jwks_uri: URI to fetch JSON Web Key Set (only for asymmetric algorithms)
202
- issuer: Expected issuer claim
203
- audience: Expected audience claim(s)
204
- algorithm: JWT signing algorithm. Supported algorithms:
205
- - Asymmetric: RS256/384/512, ES256/384/512, PS256/384/512 (default: RS256)
206
- - Symmetric: HS256, HS384, HS512
207
- required_scopes: Required scopes for all tokens
208
- base_url: Base URL for TokenVerifier protocol
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
- Validates the provided JWT bearer token.
369
+ Validate a JWT bearer token and return an AccessToken when the token is valid.
370
370
 
371
- Args:
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 object if valid, None if invalid or expired
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 and claims.get("iss") != self.issuer:
404
- self.logger.debug(
405
- "Token validation failed: issuer mismatch for client %s",
406
- client_id,
407
- )
408
- self.logger.info("Bearer token rejected for client %s", client_id)
409
- return None
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
- - For projects created after May 1st, 2025, asymmetric RS256 keys are used by default
56
- - For older projects, consider migrating to asymmetric keys for better security
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
- required_scopes: Optional list of scopes to require for all requests
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="ES256", # Supabase uses ES256 for asymmetric keys
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
- If called outside of a request context, this will raise a ValueError.
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 as e:
192
- raise ValueError("Context is not available outside of a request") from e
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
@@ -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
- from mcp.server.lowlevel.server import request_ctx
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(content=value.content, structured_content=value.structured_content)
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, structured_content=self.structured_content
78
+ content=self.content,
79
+ structured_content=self.structured_content,
80
+ meta=self.meta,
74
81
  )
75
82
 
76
83