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 +9 -0
- fastmcp/client/auth/oauth.py +4 -1
- fastmcp/client/transports.py +19 -13
- fastmcp/prompts/prompt_manager.py +3 -4
- fastmcp/resources/resource_manager.py +9 -14
- fastmcp/server/auth/auth.py +20 -5
- fastmcp/server/auth/oauth_proxy.py +118 -25
- fastmcp/server/auth/providers/supabase.py +11 -6
- fastmcp/server/context.py +28 -4
- fastmcp/server/dependencies.py +5 -0
- fastmcp/server/elicitation.py +7 -3
- fastmcp/server/middleware/error_handling.py +1 -1
- fastmcp/server/openapi/components.py +2 -4
- fastmcp/server/server.py +24 -57
- fastmcp/server/tasks/handlers.py +19 -10
- fastmcp/server/tasks/protocol.py +12 -8
- fastmcp/server/tasks/subscriptions.py +3 -5
- fastmcp/settings.py +15 -0
- fastmcp/tools/tool.py +5 -1
- fastmcp/tools/tool_manager.py +5 -7
- fastmcp/utilities/cli.py +49 -42
- fastmcp/utilities/json_schema.py +40 -0
- fastmcp/utilities/openapi/schemas.py +4 -4
- fastmcp/utilities/version_check.py +153 -0
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/METADATA +6 -3
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/RECORD +29 -28
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/licenses/LICENSE +0 -0
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(
|
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -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=
|
|
114
|
+
ttl=60 * 60 * 24 * 365, # 1 year
|
|
112
115
|
)
|
|
113
116
|
|
|
114
117
|
@override
|
fastmcp/client/transports.py
CHANGED
|
@@ -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
|
-
#
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
111
|
-
|
|
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
|
|
272
|
-
except
|
|
273
|
-
|
|
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
|
|
303
|
-
except
|
|
304
|
-
|
|
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
|
|
326
|
-
|
|
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}"
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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.
|
|
1002
|
-
2.
|
|
1003
|
-
3.
|
|
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=
|
|
1212
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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=
|
|
1453
|
-
|
|
1454
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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}
|
|
63
|
-
- JWTs are issued by {project_url}
|
|
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}/
|
|
132
|
-
issuer=f"{self.project_url}/
|
|
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}/
|
|
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}/
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -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(
|