fastmcp 2.14.1__py3-none-any.whl → 2.14.3__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 CHANGED
@@ -28,6 +28,7 @@ from fastmcp.utilities.inspect import (
28
28
  )
29
29
  from fastmcp.utilities.logging import get_logger
30
30
  from fastmcp.utilities.mcp_server_config import MCPServerConfig
31
+ from fastmcp.utilities.version_check import check_for_newer_version
31
32
 
32
33
  logger = get_logger("cli")
33
34
  console = Console()
@@ -122,6 +123,14 @@ def version(
122
123
  else:
123
124
  console.print(g)
124
125
 
126
+ # Check for updates (not included in --copy output)
127
+ if newer_version := check_for_newer_version():
128
+ console.print()
129
+ console.print(
130
+ f"[bold]🎉 FastMCP update available:[/bold] [green]{newer_version}[/green]"
131
+ )
132
+ console.print("[dim]Run: pip install --upgrade fastmcp[/dim]")
133
+
125
134
 
126
135
  @app.command
127
136
  async def dev(
@@ -105,10 +105,13 @@ class TokenStorageAdapter(TokenStorage):
105
105
 
106
106
  @override
107
107
  async def set_tokens(self, tokens: OAuthToken) -> None:
108
+ # Don't set TTL based on access token expiry - the refresh token may be
109
+ # valid much longer. Use 1 year as a reasonable upper bound; the OAuth
110
+ # provider handles actual token expiry/refresh logic.
108
111
  await self._storage_oauth_token.put(
109
112
  key=self._get_token_cache_key(),
110
113
  value=tokens,
111
- ttl=tokens.expires_in,
114
+ ttl=60 * 60 * 24 * 365, # 1 year
112
115
  )
113
116
 
114
117
  @override
@@ -25,7 +25,7 @@ from mcp.client.sse import sse_client
25
25
  from mcp.client.stdio import stdio_client
26
26
  from mcp.client.streamable_http import streamable_http_client
27
27
  from mcp.server.fastmcp import FastMCP as FastMCP1Server
28
- from mcp.shared._httpx_utils import McpHttpClientFactory
28
+ from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
29
29
  from mcp.shared.memory import create_client_server_memory_streams
30
30
  from pydantic import AnyUrl
31
31
  from typing_extensions import TypedDict, Unpack
@@ -284,25 +284,31 @@ class StreamableHttpTransport(ClientTransport):
284
284
  # need to be forwarded to the remote server.
285
285
  headers = get_http_headers() | self.headers
286
286
 
287
- # Build httpx client configuration
288
- httpx_client_kwargs: dict[str, Any] = {
289
- "headers": headers,
290
- "auth": self.auth,
291
- "follow_redirects": True,
292
- }
293
-
294
- # Configure timeout if provided (convert timedelta to seconds for httpx)
287
+ # Configure timeout if provided, preserving MCP's 30s connect default
288
+ timeout: httpx.Timeout | None = None
295
289
  if session_kwargs.get("read_timeout_seconds") is not None:
296
290
  read_timeout_seconds = cast(
297
291
  datetime.timedelta, session_kwargs.get("read_timeout_seconds")
298
292
  )
299
- httpx_client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
293
+ timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds())
300
294
 
301
- # Create httpx client from factory or use default
295
+ # Create httpx client from factory or use default with MCP-appropriate timeouts
296
+ # create_mcp_http_client uses 30s connect/5min read timeout by default,
297
+ # and always enables follow_redirects
302
298
  if self.httpx_client_factory is not None:
303
- http_client = self.httpx_client_factory(**httpx_client_kwargs)
299
+ # Factory clients get the full kwargs for backwards compatibility
300
+ http_client = self.httpx_client_factory(
301
+ headers=headers,
302
+ auth=self.auth,
303
+ follow_redirects=True,
304
+ **({"timeout": timeout} if timeout else {}),
305
+ )
304
306
  else:
305
- http_client = httpx.AsyncClient(**httpx_client_kwargs)
307
+ http_client = create_mcp_http_client(
308
+ headers=headers,
309
+ timeout=timeout,
310
+ auth=self.auth,
311
+ )
306
312
 
307
313
  # Ensure httpx client is closed after use
308
314
  async with (
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from mcp import GetPromptResult
8
8
 
9
9
  from fastmcp import settings
10
- from fastmcp.exceptions import NotFoundError, PromptError
10
+ from fastmcp.exceptions import FastMCPError, NotFoundError, PromptError
11
11
  from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
12
12
  from fastmcp.settings import DuplicateBehavior
13
13
  from fastmcp.utilities.logging import get_logger
@@ -107,9 +107,8 @@ class PromptManager:
107
107
  try:
108
108
  messages = await prompt.render(arguments)
109
109
  return GetPromptResult(description=prompt.description, messages=messages)
110
- except PromptError as e:
111
- logger.exception(f"Error rendering prompt {name!r}")
112
- raise e
110
+ except FastMCPError:
111
+ raise
113
112
  except Exception as e:
114
113
  logger.exception(f"Error rendering prompt {name!r}")
115
114
  if self.mask_error_details:
@@ -10,7 +10,7 @@ from typing import Any
10
10
  from pydantic import AnyUrl
11
11
 
12
12
  from fastmcp import settings
13
- from fastmcp.exceptions import NotFoundError, ResourceError
13
+ from fastmcp.exceptions import FastMCPError, NotFoundError, ResourceError
14
14
  from fastmcp.resources.resource import Resource
15
15
  from fastmcp.resources.template import (
16
16
  ResourceTemplate,
@@ -268,10 +268,9 @@ class ResourceManager:
268
268
  uri_str,
269
269
  params=params,
270
270
  )
271
- # Pass through ResourceErrors as-is
272
- except ResourceError as e:
273
- logger.error(f"Error creating resource from template: {e}")
274
- raise e
271
+ # Pass through FastMCPErrors as-is
272
+ except FastMCPError:
273
+ raise
275
274
  # Handle other exceptions
276
275
  except Exception as e:
277
276
  logger.error(f"Error creating resource from template: {e}")
@@ -299,10 +298,9 @@ class ResourceManager:
299
298
  try:
300
299
  return await resource.read()
301
300
 
302
- # raise ResourceErrors as-is
303
- except ResourceError as e:
304
- logger.exception(f"Error reading resource {uri_str!r}")
305
- raise e
301
+ # raise FastMCPErrors as-is
302
+ except FastMCPError:
303
+ raise
306
304
 
307
305
  # Handle other exceptions
308
306
  except Exception as e:
@@ -322,11 +320,8 @@ class ResourceManager:
322
320
  try:
323
321
  resource = await template.create_resource(uri_str, params=params)
324
322
  return await resource.read()
325
- except ResourceError as e:
326
- logger.exception(
327
- f"Error reading resource from template {uri_str!r}"
328
- )
329
- raise e
323
+ except FastMCPError:
324
+ raise
330
325
  except Exception as e:
331
326
  logger.exception(
332
327
  f"Error reading resource from template {uri_str!r}"
@@ -114,6 +114,8 @@ class AuthProvider(TokenVerifierProtocol):
114
114
  base_url = AnyHttpUrl(base_url)
115
115
  self.base_url = base_url
116
116
  self.required_scopes = required_scopes or []
117
+ self._mcp_path: str | None = None
118
+ self._resource_url: AnyHttpUrl | None = None
117
119
 
118
120
  async def verify_token(self, token: str) -> AccessToken | None:
119
121
  """Verify a bearer token and return access info if valid.
@@ -128,6 +130,20 @@ class AuthProvider(TokenVerifierProtocol):
128
130
  """
129
131
  raise NotImplementedError("Subclasses must implement verify_token")
130
132
 
133
+ def set_mcp_path(self, mcp_path: str | None) -> None:
134
+ """Set the MCP endpoint path and compute resource URL.
135
+
136
+ This method is called by get_routes() to configure the expected
137
+ resource URL before route creation. Subclasses can override to
138
+ perform additional initialization that depends on knowing the
139
+ MCP endpoint path.
140
+
141
+ Args:
142
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
143
+ """
144
+ self._mcp_path = mcp_path
145
+ self._resource_url = self._get_resource_url(mcp_path)
146
+
131
147
  def get_routes(
132
148
  self,
133
149
  mcp_path: str | None = None,
@@ -407,6 +423,8 @@ class OAuthProvider(
407
423
  Returns:
408
424
  List of OAuth routes
409
425
  """
426
+ # Configure resource URL before creating routes
427
+ self.set_mcp_path(mcp_path)
410
428
 
411
429
  # Create standard OAuth authorization server routes
412
430
  # Pass base_url as issuer_url to ensure metadata declares endpoints where
@@ -451,11 +469,8 @@ class OAuthProvider(
451
469
  else:
452
470
  oauth_routes.append(route)
453
471
 
454
- # Get the resource URL based on the MCP path
455
- resource_url = self._get_resource_url(mcp_path)
456
-
457
472
  # Add protected resource routes if this server is also acting as a resource server
458
- if resource_url:
473
+ if self._resource_url:
459
474
  supported_scopes = (
460
475
  self.client_registration_options.valid_scopes
461
476
  if self.client_registration_options
@@ -463,7 +478,7 @@ class OAuthProvider(
463
478
  else self.required_scopes
464
479
  )
465
480
  protected_routes = create_protected_resource_routes(
466
- resource_url=resource_url,
481
+ resource_url=self._resource_url,
467
482
  authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
468
483
  scopes_supported=supported_scopes,
469
484
  )
@@ -34,7 +34,6 @@ from authlib.integrations.httpx_client import AsyncOAuth2Client
34
34
  from cryptography.fernet import Fernet
35
35
  from key_value.aio.adapters.pydantic import PydanticAdapter
36
36
  from key_value.aio.protocols import AsyncKeyValue
37
- from key_value.aio.stores.disk import DiskStore
38
37
  from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
39
38
  from mcp.server.auth.provider import (
40
39
  AccessToken,
@@ -805,14 +804,16 @@ class OAuthProxy(OAuthProvider):
805
804
  salt="fastmcp-jwt-signing-key",
806
805
  )
807
806
 
808
- self._jwt_issuer: JWTIssuer = JWTIssuer(
809
- issuer=str(self.base_url),
810
- audience=f"{str(self.base_url).rstrip('/')}/mcp",
811
- signing_key=jwt_signing_key,
812
- )
807
+ # Store JWT signing key for deferred JWTIssuer creation in set_mcp_path()
808
+ self._jwt_signing_key: bytes = jwt_signing_key
809
+ # JWTIssuer will be created in set_mcp_path() with correct audience
810
+ self._jwt_issuer: JWTIssuer | None = None
813
811
 
814
812
  # If the user does not provide a store, we will provide an encrypted disk store
815
813
  if client_storage is None:
814
+ # Import lazily to avoid sqlite3 dependency when not using OAuthProxy
815
+ from key_value.aio.stores.disk import DiskStore
816
+
816
817
  storage_encryption_key = derive_jwt_key(
817
818
  high_entropy_material=jwt_signing_key.decode(),
818
819
  salt="fastmcp-storage-encryption-key",
@@ -897,6 +898,47 @@ class OAuthProxy(OAuthProvider):
897
898
  self._upstream_authorization_endpoint,
898
899
  )
899
900
 
901
+ # -------------------------------------------------------------------------
902
+ # MCP Path Configuration
903
+ # -------------------------------------------------------------------------
904
+
905
+ def set_mcp_path(self, mcp_path: str | None) -> None:
906
+ """Set the MCP endpoint path and create JWTIssuer with correct audience.
907
+
908
+ This method is called by get_routes() to configure the resource URL
909
+ and create the JWTIssuer. The JWT audience is set to the full resource
910
+ URL (e.g., http://localhost:8000/mcp) to ensure tokens are bound to
911
+ this specific MCP endpoint.
912
+
913
+ Args:
914
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
915
+ """
916
+ super().set_mcp_path(mcp_path)
917
+
918
+ # Create JWT issuer with correct audience based on actual MCP path
919
+ # This ensures tokens are bound to the specific resource URL
920
+ self._jwt_issuer = JWTIssuer(
921
+ issuer=str(self.base_url),
922
+ audience=str(self._resource_url),
923
+ signing_key=self._jwt_signing_key,
924
+ )
925
+
926
+ logger.debug("Configured OAuth proxy for resource URL: %s", self._resource_url)
927
+
928
+ @property
929
+ def jwt_issuer(self) -> JWTIssuer:
930
+ """Get the JWT issuer, ensuring it has been initialized.
931
+
932
+ The JWT issuer is created when set_mcp_path() is called (via get_routes()).
933
+ This property ensures a clear error if used before initialization.
934
+ """
935
+ if self._jwt_issuer is None:
936
+ raise RuntimeError(
937
+ "JWT issuer not initialized. Ensure get_routes() is called "
938
+ "before token operations."
939
+ )
940
+ return self._jwt_issuer
941
+
900
942
  # -------------------------------------------------------------------------
901
943
  # PKCE Helper Methods
902
944
  # -------------------------------------------------------------------------
@@ -998,13 +1040,29 @@ class OAuthProxy(OAuthProvider):
998
1040
  """Start OAuth transaction and route through consent interstitial.
999
1041
 
1000
1042
  Flow:
1001
- 1. Store transaction with client details and PKCE (if forwarding)
1002
- 2. Return local /consent URL; browser visits consent first
1003
- 3. Consent handler redirects to upstream IdP if approved/already approved
1043
+ 1. Validate client's resource matches server's resource URL (security check)
1044
+ 2. Store transaction with client details and PKCE (if forwarding)
1045
+ 3. Return local /consent URL; browser visits consent first
1046
+ 4. Consent handler redirects to upstream IdP if approved/already approved
1004
1047
 
1005
1048
  If consent is disabled (require_authorization_consent=False), skip the consent screen
1006
1049
  and redirect directly to the upstream IdP.
1007
1050
  """
1051
+ # Security check: validate client's requested resource matches this server
1052
+ # This prevents tokens intended for one server from being used on another
1053
+ client_resource = getattr(params, "resource", None)
1054
+ if client_resource and self._resource_url:
1055
+ if str(client_resource) != str(self._resource_url):
1056
+ logger.warning(
1057
+ "Resource mismatch: client requested %s but server is %s",
1058
+ client_resource,
1059
+ self._resource_url,
1060
+ )
1061
+ raise AuthorizeError(
1062
+ error="invalid_target", # type: ignore[arg-type]
1063
+ error_description="Resource does not match this server",
1064
+ )
1065
+
1008
1066
  # Generate transaction ID for this authorization request
1009
1067
  txn_id = secrets.token_urlsafe(32)
1010
1068
 
@@ -1163,12 +1221,24 @@ class OAuthProxy(OAuthProvider):
1163
1221
  # - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps)
1164
1222
  if "expires_in" in idp_tokens:
1165
1223
  expires_in = int(idp_tokens["expires_in"])
1224
+ logger.debug(
1225
+ "Access token TTL: %d seconds (from IdP expires_in)", expires_in
1226
+ )
1166
1227
  elif self._fallback_access_token_expiry_seconds is not None:
1167
1228
  expires_in = self._fallback_access_token_expiry_seconds
1229
+ logger.debug(
1230
+ "Access token TTL: %d seconds (using configured fallback)", expires_in
1231
+ )
1168
1232
  elif idp_tokens.get("refresh_token"):
1169
1233
  expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
1234
+ logger.debug(
1235
+ "Access token TTL: %d seconds (default, has refresh token)", expires_in
1236
+ )
1170
1237
  else:
1171
1238
  expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS
1239
+ logger.debug(
1240
+ "Access token TTL: %d seconds (default, no refresh token)", expires_in
1241
+ )
1172
1242
 
1173
1243
  # Calculate refresh token expiry if provided by upstream
1174
1244
  # Some providers include refresh_expires_in, some don't
@@ -1208,15 +1278,16 @@ class OAuthProxy(OAuthProvider):
1208
1278
  await self._upstream_token_store.put(
1209
1279
  key=upstream_token_id,
1210
1280
  value=upstream_token_set,
1211
- ttl=refresh_expires_in
1212
- or expires_in, # Auto-expire when refresh token, or access token expires
1281
+ ttl=max(
1282
+ refresh_expires_in or 0, expires_in, 1
1283
+ ), # Keep until longest-lived token expires (min 1s for safety)
1213
1284
  )
1214
1285
  logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8])
1215
1286
 
1216
1287
  # Issue minimal FastMCP access token (just a reference via JTI)
1217
1288
  if client.client_id is None:
1218
1289
  raise TokenError("invalid_client", "Client ID is required")
1219
- fastmcp_access_token = self._jwt_issuer.issue_access_token(
1290
+ fastmcp_access_token = self.jwt_issuer.issue_access_token(
1220
1291
  client_id=client.client_id,
1221
1292
  scopes=authorization_code.scopes,
1222
1293
  jti=access_jti,
@@ -1227,7 +1298,7 @@ class OAuthProxy(OAuthProvider):
1227
1298
  # Use upstream refresh token expiry to align lifetimes
1228
1299
  fastmcp_refresh_token = None
1229
1300
  if refresh_jti and refresh_expires_in:
1230
- fastmcp_refresh_token = self._jwt_issuer.issue_refresh_token(
1301
+ fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token(
1231
1302
  client_id=client.client_id,
1232
1303
  scopes=authorization_code.scopes,
1233
1304
  jti=refresh_jti,
@@ -1352,7 +1423,7 @@ class OAuthProxy(OAuthProvider):
1352
1423
  """
1353
1424
  # Verify FastMCP refresh token
1354
1425
  try:
1355
- refresh_payload = self._jwt_issuer.verify_token(refresh_token.token)
1426
+ refresh_payload = self.jwt_issuer.verify_token(refresh_token.token)
1356
1427
  refresh_jti = refresh_payload["jti"]
1357
1428
  except Exception as e:
1358
1429
  logger.debug("FastMCP refresh token validation failed: %s", e)
@@ -1409,10 +1480,21 @@ class OAuthProxy(OAuthProvider):
1409
1480
  # (user override still applies if set)
1410
1481
  if "expires_in" in token_response:
1411
1482
  new_expires_in = int(token_response["expires_in"])
1483
+ logger.debug(
1484
+ "Refreshed access token TTL: %d seconds (from IdP expires_in)",
1485
+ new_expires_in,
1486
+ )
1412
1487
  elif self._fallback_access_token_expiry_seconds is not None:
1413
1488
  new_expires_in = self._fallback_access_token_expiry_seconds
1489
+ logger.debug(
1490
+ "Refreshed access token TTL: %d seconds (using configured fallback)",
1491
+ new_expires_in,
1492
+ )
1414
1493
  else:
1415
1494
  new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
1495
+ logger.debug(
1496
+ "Refreshed access token TTL: %d seconds (default)", new_expires_in
1497
+ )
1416
1498
  upstream_token_set.access_token = token_response["access_token"]
1417
1499
  upstream_token_set.expires_at = time.time() + new_expires_in
1418
1500
 
@@ -1446,22 +1528,25 @@ class OAuthProxy(OAuthProvider):
1446
1528
  )
1447
1529
 
1448
1530
  upstream_token_set.raw_token_data = token_response
1531
+ # Calculate refresh TTL for storage
1532
+ refresh_ttl = new_refresh_expires_in or (
1533
+ int(upstream_token_set.refresh_token_expires_at - time.time())
1534
+ if upstream_token_set.refresh_token_expires_at
1535
+ else 60 * 60 * 24 * 30 # Default to 30 days if unknown
1536
+ )
1449
1537
  await self._upstream_token_store.put(
1450
1538
  key=upstream_token_set.upstream_token_id,
1451
1539
  value=upstream_token_set,
1452
- ttl=new_refresh_expires_in
1453
- or (
1454
- int(upstream_token_set.refresh_token_expires_at - time.time())
1455
- if upstream_token_set.refresh_token_expires_at
1456
- else 60 * 60 * 24 * 30 # Default to 30 days if unknown
1457
- ), # Auto-expire when refresh token expires
1540
+ ttl=max(
1541
+ refresh_ttl, new_expires_in, 1
1542
+ ), # Keep until longest-lived token expires (min 1s for safety)
1458
1543
  )
1459
1544
 
1460
1545
  # Issue new minimal FastMCP access token (just a reference via JTI)
1461
1546
  if client.client_id is None:
1462
1547
  raise TokenError("invalid_client", "Client ID is required")
1463
1548
  new_access_jti = secrets.token_urlsafe(32)
1464
- new_fastmcp_access = self._jwt_issuer.issue_access_token(
1549
+ new_fastmcp_access = self.jwt_issuer.issue_access_token(
1465
1550
  client_id=client.client_id,
1466
1551
  scopes=scopes,
1467
1552
  jti=new_access_jti,
@@ -1482,7 +1567,7 @@ class OAuthProxy(OAuthProvider):
1482
1567
  # Issue NEW minimal FastMCP refresh token (rotation for security)
1483
1568
  # Use upstream refresh token expiry to align lifetimes
1484
1569
  new_refresh_jti = secrets.token_urlsafe(32)
1485
- new_fastmcp_refresh = self._jwt_issuer.issue_refresh_token(
1570
+ new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token(
1486
1571
  client_id=client.client_id,
1487
1572
  scopes=scopes,
1488
1573
  jti=new_refresh_jti,
@@ -1491,7 +1576,7 @@ class OAuthProxy(OAuthProvider):
1491
1576
  )
1492
1577
 
1493
1578
  # Store new refresh token JTI mapping with aligned expiry
1494
- refresh_ttl = new_refresh_expires_in or 60 * 60 * 24 * 30
1579
+ # (reuse refresh_ttl calculated above for upstream token store)
1495
1580
  await self._jti_mapping_store.put(
1496
1581
  key=new_refresh_jti,
1497
1582
  value=JTIMapping(
@@ -1558,13 +1643,16 @@ class OAuthProxy(OAuthProvider):
1558
1643
  """
1559
1644
  try:
1560
1645
  # 1. Verify FastMCP JWT signature and claims
1561
- payload = self._jwt_issuer.verify_token(token)
1646
+ payload = self.jwt_issuer.verify_token(token)
1562
1647
  jti = payload["jti"]
1563
1648
 
1564
1649
  # 2. Look up upstream token via JTI mapping
1565
1650
  jti_mapping = await self._jti_mapping_store.get(key=jti)
1566
1651
  if not jti_mapping:
1567
- logger.debug("JTI mapping not found: %s", jti)
1652
+ logger.info(
1653
+ "JTI mapping not found (token may have expired): jti=%s...",
1654
+ jti[:16],
1655
+ )
1568
1656
  return None
1569
1657
 
1570
1658
  upstream_token_set = await self._upstream_token_store.get(
@@ -1807,6 +1895,11 @@ class OAuthProxy(OAuthProvider):
1807
1895
  logger.debug(
1808
1896
  f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})"
1809
1897
  )
1898
+ logger.debug(
1899
+ "IdP token response: expires_in=%s, has_refresh_token=%s",
1900
+ idp_tokens.get("expires_in"),
1901
+ "refresh_token" in idp_tokens,
1902
+ )
1810
1903
 
1811
1904
  except Exception as e:
1812
1905
  logger.error("IdP token exchange failed: %s", e)
@@ -34,6 +34,7 @@ class SupabaseProviderSettings(BaseSettings):
34
34
 
35
35
  project_url: AnyHttpUrl
36
36
  base_url: AnyHttpUrl
37
+ auth_route: str = "/auth/v1"
37
38
  algorithm: Literal["HS256", "RS256", "ES256"] = "ES256"
38
39
  required_scopes: list[str] | None = None
39
40
 
@@ -59,8 +60,8 @@ class SupabaseProvider(RemoteAuthProvider):
59
60
  - Asymmetric keys (RS256/ES256) are recommended for production
60
61
 
61
62
  2. JWT Verification:
62
- - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
63
- - JWTs are issued by {project_url}/auth/v1
63
+ - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json
64
+ - JWTs are issued by {project_url}{auth_route}
64
65
  - Tokens are cached for up to 10 minutes by Supabase's edge servers
65
66
  - Algorithm must match your Supabase Auth configuration
66
67
 
@@ -93,6 +94,7 @@ class SupabaseProvider(RemoteAuthProvider):
93
94
  *,
94
95
  project_url: AnyHttpUrl | str | NotSetT = NotSet,
95
96
  base_url: AnyHttpUrl | str | NotSetT = NotSet,
97
+ auth_route: str | NotSetT = NotSet,
96
98
  algorithm: Literal["HS256", "RS256", "ES256"] | NotSetT = NotSet,
97
99
  required_scopes: list[str] | NotSetT | None = NotSet,
98
100
  token_verifier: TokenVerifier | None = None,
@@ -102,6 +104,7 @@ class SupabaseProvider(RemoteAuthProvider):
102
104
  Args:
103
105
  project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
104
106
  base_url: Public URL of this FastMCP server
107
+ auth_route: Supabase Auth route. Defaults to "/auth/v1".
105
108
  algorithm: JWT signing algorithm (HS256, RS256, or ES256). Must match your
106
109
  Supabase Auth configuration. Defaults to ES256.
107
110
  required_scopes: Optional list of scopes to require for all requests.
@@ -115,6 +118,7 @@ class SupabaseProvider(RemoteAuthProvider):
115
118
  for k, v in {
116
119
  "project_url": project_url,
117
120
  "base_url": base_url,
121
+ "auth_route": auth_route,
118
122
  "algorithm": algorithm,
119
123
  "required_scopes": required_scopes,
120
124
  }.items()
@@ -124,12 +128,13 @@ class SupabaseProvider(RemoteAuthProvider):
124
128
 
125
129
  self.project_url = str(settings.project_url).rstrip("/")
126
130
  self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
131
+ self.auth_route = settings.auth_route.strip("/")
127
132
 
128
133
  # Create default JWT verifier if none provided
129
134
  if token_verifier is None:
130
135
  token_verifier = JWTVerifier(
131
- jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json",
132
- issuer=f"{self.project_url}/auth/v1",
136
+ jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json",
137
+ issuer=f"{self.project_url}/{self.auth_route}",
133
138
  algorithm=settings.algorithm,
134
139
  required_scopes=settings.required_scopes,
135
140
  )
@@ -137,7 +142,7 @@ class SupabaseProvider(RemoteAuthProvider):
137
142
  # Initialize RemoteAuthProvider with Supabase as the authorization server
138
143
  super().__init__(
139
144
  token_verifier=token_verifier,
140
- authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")],
145
+ authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")],
141
146
  base_url=self.base_url,
142
147
  )
143
148
 
@@ -162,7 +167,7 @@ class SupabaseProvider(RemoteAuthProvider):
162
167
  try:
163
168
  async with httpx.AsyncClient() as client:
164
169
  response = await client.get(
165
- f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server"
170
+ f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
166
171
  )
167
172
  response.raise_for_status()
168
173
  metadata = response.json()
fastmcp/server/context.py CHANGED
@@ -185,10 +185,24 @@ class Context:
185
185
  self._tokens.append(token)
186
186
 
187
187
  # Set current server for dependency injection (use weakref to avoid reference cycles)
188
- from fastmcp.server.dependencies import _current_server
188
+ from fastmcp.server.dependencies import (
189
+ _current_docket,
190
+ _current_server,
191
+ _current_worker,
192
+ )
189
193
 
190
194
  self._server_token = _current_server.set(weakref.ref(self.fastmcp))
191
195
 
196
+ # Set docket/worker from server instance for this request's context.
197
+ # This ensures ContextVars work even in environments (like Lambda) where
198
+ # lifespan ContextVars don't propagate to request handlers.
199
+ server = self.fastmcp
200
+ if server._docket is not None:
201
+ self._docket_token = _current_docket.set(server._docket)
202
+
203
+ if server._worker is not None:
204
+ self._worker_token = _current_worker.set(server._worker)
205
+
192
206
  return self
193
207
 
194
208
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -196,10 +210,20 @@ class Context:
196
210
  # Flush any remaining notifications before exiting
197
211
  await self._flush_notifications()
198
212
 
199
- # Reset server token
200
- if hasattr(self, "_server_token"):
201
- from fastmcp.server.dependencies import _current_server
213
+ # Reset server/docket/worker tokens
214
+ from fastmcp.server.dependencies import (
215
+ _current_docket,
216
+ _current_server,
217
+ _current_worker,
218
+ )
202
219
 
220
+ if hasattr(self, "_worker_token"):
221
+ _current_worker.reset(self._worker_token)
222
+ delattr(self, "_worker_token")
223
+ if hasattr(self, "_docket_token"):
224
+ _current_docket.reset(self._docket_token)
225
+ delattr(self, "_docket_token")
226
+ if hasattr(self, "_server_token"):
203
227
  _current_server.reset(self._server_token)
204
228
  delattr(self, "_server_token")
205
229
 
@@ -21,6 +21,7 @@ from mcp.server.auth.provider import (
21
21
  from mcp.server.lowlevel.server import request_ctx
22
22
  from starlette.requests import Request
23
23
 
24
+ from fastmcp.exceptions import FastMCPError
24
25
  from fastmcp.server.auth import AccessToken
25
26
  from fastmcp.server.http import _current_http_request
26
27
  from fastmcp.utilities.types import is_class_member_of_type
@@ -188,6 +189,10 @@ async def _resolve_fastmcp_dependencies(
188
189
  resolved[parameter] = await stack.enter_async_context(
189
190
  dependency
190
191
  )
192
+ except FastMCPError:
193
+ # Let FastMCPError subclasses (ToolError, ResourceError, etc.)
194
+ # propagate unchanged so they can be handled appropriately
195
+ raise
191
196
  except Exception as error:
192
197
  fn_name = getattr(fn, "__name__", repr(fn))
193
198
  raise RuntimeError(