fastmcp 2.12.3__py3-none-any.whl → 2.12.4__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/install/gemini_cli.py +0 -1
- fastmcp/cli/run.py +2 -2
- fastmcp/client/auth/oauth.py +49 -36
- fastmcp/client/client.py +12 -2
- fastmcp/contrib/mcp_mixin/README.md +2 -2
- fastmcp/experimental/utilities/openapi/schemas.py +31 -5
- fastmcp/server/auth/auth.py +3 -3
- fastmcp/server/auth/oauth_proxy.py +42 -12
- fastmcp/server/auth/oidc_proxy.py +348 -0
- fastmcp/server/auth/providers/auth0.py +174 -0
- fastmcp/server/auth/providers/aws.py +237 -0
- fastmcp/server/auth/providers/azure.py +6 -2
- fastmcp/server/auth/providers/descope.py +172 -0
- fastmcp/server/auth/providers/github.py +6 -2
- fastmcp/server/auth/providers/google.py +6 -2
- fastmcp/server/auth/providers/workos.py +6 -2
- fastmcp/server/context.py +7 -6
- fastmcp/server/http.py +1 -1
- fastmcp/server/middleware/logging.py +147 -116
- fastmcp/server/middleware/middleware.py +3 -2
- fastmcp/server/openapi.py +5 -1
- fastmcp/server/server.py +36 -31
- fastmcp/settings.py +27 -5
- fastmcp/tools/tool.py +4 -2
- fastmcp/utilities/json_schema.py +18 -1
- fastmcp/utilities/logging.py +66 -4
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +2 -1
- fastmcp/utilities/storage.py +204 -0
- fastmcp/utilities/tests.py +8 -6
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.4.dist-info}/METADATA +120 -47
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.4.dist-info}/RECORD +34 -29
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.3.dist-info → fastmcp-2.12.4.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/run.py
CHANGED
|
@@ -184,8 +184,8 @@ async def run_command(
|
|
|
184
184
|
kwargs["port"] = port
|
|
185
185
|
if path:
|
|
186
186
|
kwargs["path"] = path
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
if log_level:
|
|
188
|
+
kwargs["log_level"] = log_level
|
|
189
189
|
|
|
190
190
|
if not show_banner:
|
|
191
191
|
kwargs["show_banner"] = False
|
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import json
|
|
5
4
|
import webbrowser
|
|
6
5
|
from asyncio import Future
|
|
7
6
|
from collections.abc import AsyncGenerator
|
|
@@ -29,6 +28,7 @@ from fastmcp.client.oauth_callback import (
|
|
|
29
28
|
)
|
|
30
29
|
from fastmcp.utilities.http import find_available_port
|
|
31
30
|
from fastmcp.utilities.logging import get_logger
|
|
31
|
+
from fastmcp.utilities.storage import JSONFileStorage
|
|
32
32
|
|
|
33
33
|
__all__ = ["OAuth"]
|
|
34
34
|
|
|
@@ -62,13 +62,14 @@ class FileTokenStorage(TokenStorage):
|
|
|
62
62
|
Implements the mcp.client.auth.TokenStorage protocol.
|
|
63
63
|
|
|
64
64
|
Each instance is tied to a specific server URL for proper token isolation.
|
|
65
|
+
Uses JSONFileStorage internally for consistent file handling.
|
|
65
66
|
"""
|
|
66
67
|
|
|
67
68
|
def __init__(self, server_url: str, cache_dir: Path | None = None):
|
|
68
69
|
"""Initialize storage for a specific server URL."""
|
|
69
70
|
self.server_url = server_url
|
|
70
|
-
|
|
71
|
-
self.cache_dir
|
|
71
|
+
# Use JSONFileStorage for actual file operations
|
|
72
|
+
self._storage = JSONFileStorage(cache_dir or default_cache_dir())
|
|
72
73
|
|
|
73
74
|
@staticmethod
|
|
74
75
|
def get_base_url(url: str) -> str:
|
|
@@ -76,28 +77,33 @@ class FileTokenStorage(TokenStorage):
|
|
|
76
77
|
parsed = urlparse(url)
|
|
77
78
|
return f"{parsed.scheme}://{parsed.netloc}"
|
|
78
79
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
80
|
+
def _get_storage_key(self, file_type: Literal["client_info", "tokens"]) -> str:
|
|
81
|
+
"""Get the storage key for the specified data type.
|
|
82
|
+
|
|
83
|
+
JSONFileStorage will handle making the key filesystem-safe.
|
|
84
|
+
"""
|
|
81
85
|
base_url = self.get_base_url(self.server_url)
|
|
82
|
-
return
|
|
83
|
-
base_url.replace("://", "_")
|
|
84
|
-
.replace(".", "_")
|
|
85
|
-
.replace("/", "_")
|
|
86
|
-
.replace(":", "_")
|
|
87
|
-
)
|
|
86
|
+
return f"{base_url}_{file_type}"
|
|
88
87
|
|
|
89
88
|
def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
|
|
90
|
-
"""Get the file path for the specified cache file type.
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
"""Get the file path for the specified cache file type.
|
|
90
|
+
|
|
91
|
+
This method is kept for backward compatibility with tests that access _get_file_path.
|
|
92
|
+
"""
|
|
93
|
+
key = self._get_storage_key(file_type)
|
|
94
|
+
return self._storage._get_file_path(key)
|
|
93
95
|
|
|
94
96
|
async def get_tokens(self) -> OAuthToken | None:
|
|
95
97
|
"""Load tokens from file storage."""
|
|
96
|
-
|
|
98
|
+
key = self._get_storage_key("tokens")
|
|
99
|
+
data = await self._storage.get(key)
|
|
100
|
+
|
|
101
|
+
if data is None:
|
|
102
|
+
return None
|
|
97
103
|
|
|
98
104
|
try:
|
|
99
|
-
# Parse
|
|
100
|
-
stored = stored_token_adapter.
|
|
105
|
+
# Parse and validate as StoredToken
|
|
106
|
+
stored = stored_token_adapter.validate_python(data)
|
|
101
107
|
|
|
102
108
|
# Check if token is expired
|
|
103
109
|
if stored.expires_at is not None:
|
|
@@ -117,15 +123,15 @@ class FileTokenStorage(TokenStorage):
|
|
|
117
123
|
|
|
118
124
|
return stored.token_payload
|
|
119
125
|
|
|
120
|
-
except
|
|
126
|
+
except ValidationError as e:
|
|
121
127
|
logger.debug(
|
|
122
|
-
f"Could not
|
|
128
|
+
f"Could not validate tokens for {self.get_base_url(self.server_url)}: {e}"
|
|
123
129
|
)
|
|
124
130
|
return None
|
|
125
131
|
|
|
126
132
|
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
127
133
|
"""Save tokens to file storage."""
|
|
128
|
-
|
|
134
|
+
key = self._get_storage_key("tokens")
|
|
129
135
|
|
|
130
136
|
# Calculate absolute expiry time if expires_in is present
|
|
131
137
|
expires_at = None
|
|
@@ -134,19 +140,22 @@ class FileTokenStorage(TokenStorage):
|
|
|
134
140
|
seconds=tokens.expires_in
|
|
135
141
|
)
|
|
136
142
|
|
|
137
|
-
# Create StoredToken and save using
|
|
143
|
+
# Create StoredToken and save using storage
|
|
144
|
+
# Note: JSONFileStorage will wrap this in {"data": ..., "timestamp": ...}
|
|
138
145
|
stored = StoredToken(token_payload=tokens, expires_at=expires_at)
|
|
139
|
-
|
|
140
|
-
path.write_text(stored.model_dump_json(indent=2))
|
|
146
|
+
await self._storage.set(key, stored.model_dump(mode="json"))
|
|
141
147
|
logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
|
|
142
148
|
|
|
143
149
|
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
144
150
|
"""Load client information from file storage."""
|
|
145
|
-
|
|
151
|
+
key = self._get_storage_key("client_info")
|
|
152
|
+
data = await self._storage.get(key)
|
|
153
|
+
|
|
154
|
+
if data is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
146
157
|
try:
|
|
147
|
-
client_info = OAuthClientInformationFull.
|
|
148
|
-
path.read_text()
|
|
149
|
-
)
|
|
158
|
+
client_info = OAuthClientInformationFull.model_validate(data)
|
|
150
159
|
# Check if we have corresponding valid tokens
|
|
151
160
|
# If no tokens exist, the OAuth flow was incomplete and we should
|
|
152
161
|
# force a fresh client registration
|
|
@@ -157,27 +166,31 @@ class FileTokenStorage(TokenStorage):
|
|
|
157
166
|
"OAuth flow may have been incomplete. Clearing client info to force fresh registration."
|
|
158
167
|
)
|
|
159
168
|
# Clear the incomplete client info
|
|
160
|
-
|
|
161
|
-
client_info_path.unlink(missing_ok=True)
|
|
169
|
+
await self._storage.delete(key)
|
|
162
170
|
return None
|
|
163
171
|
|
|
164
172
|
return client_info
|
|
165
|
-
except
|
|
173
|
+
except ValidationError as e:
|
|
166
174
|
logger.debug(
|
|
167
|
-
f"Could not
|
|
175
|
+
f"Could not validate client info for {self.get_base_url(self.server_url)}: {e}"
|
|
168
176
|
)
|
|
169
177
|
return None
|
|
170
178
|
|
|
171
179
|
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
172
180
|
"""Save client information to file storage."""
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
key = self._get_storage_key("client_info")
|
|
182
|
+
await self._storage.set(key, client_info.model_dump(mode="json"))
|
|
175
183
|
logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
|
|
176
184
|
|
|
177
185
|
def clear(self) -> None:
|
|
178
|
-
"""Clear all cached data for this server.
|
|
186
|
+
"""Clear all cached data for this server.
|
|
187
|
+
|
|
188
|
+
Note: This is a synchronous method for backward compatibility.
|
|
189
|
+
Uses direct file operations instead of async storage methods.
|
|
190
|
+
"""
|
|
179
191
|
file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
|
|
180
192
|
for file_type in file_types:
|
|
193
|
+
# Use the file path directly for synchronous deletion
|
|
181
194
|
path = self._get_file_path(file_type)
|
|
182
195
|
path.unlink(missing_ok=True)
|
|
183
196
|
logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
|
|
@@ -318,8 +331,8 @@ class OAuth(OAuthClientProvider):
|
|
|
318
331
|
"OAuth client not found - cached credentials may be stale"
|
|
319
332
|
)
|
|
320
333
|
|
|
321
|
-
#
|
|
322
|
-
if response.status_code not in (302, 303, 307, 308):
|
|
334
|
+
# OAuth typically returns redirects, but some providers return 200 with HTML login pages
|
|
335
|
+
if response.status_code not in (200, 302, 303, 307, 308):
|
|
323
336
|
raise RuntimeError(
|
|
324
337
|
f"Unexpected authorization response: {response.status_code}"
|
|
325
338
|
)
|
fastmcp/client/client.py
CHANGED
|
@@ -745,12 +745,15 @@ class Client(Generic[ClientTransportT]):
|
|
|
745
745
|
self,
|
|
746
746
|
ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
|
|
747
747
|
argument: dict[str, str],
|
|
748
|
+
context_arguments: dict[str, Any] | None = None,
|
|
748
749
|
) -> mcp.types.CompleteResult:
|
|
749
750
|
"""Send a completion request and return the complete MCP protocol result.
|
|
750
751
|
|
|
751
752
|
Args:
|
|
752
753
|
ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
|
|
753
754
|
argument (dict[str, str]): Arguments to pass to the completion request.
|
|
755
|
+
context_arguments (dict[str, Any] | None, optional): Optional context arguments to
|
|
756
|
+
include with the completion request. Defaults to None.
|
|
754
757
|
|
|
755
758
|
Returns:
|
|
756
759
|
mcp.types.CompleteResult: The complete response object from the protocol,
|
|
@@ -761,19 +764,24 @@ class Client(Generic[ClientTransportT]):
|
|
|
761
764
|
"""
|
|
762
765
|
logger.debug(f"[{self.name}] called complete: {ref}")
|
|
763
766
|
|
|
764
|
-
result = await self.session.complete(
|
|
767
|
+
result = await self.session.complete(
|
|
768
|
+
ref=ref, argument=argument, context_arguments=context_arguments
|
|
769
|
+
)
|
|
765
770
|
return result
|
|
766
771
|
|
|
767
772
|
async def complete(
|
|
768
773
|
self,
|
|
769
774
|
ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
|
|
770
775
|
argument: dict[str, str],
|
|
776
|
+
context_arguments: dict[str, Any] | None = None,
|
|
771
777
|
) -> mcp.types.Completion:
|
|
772
778
|
"""Send a completion request to the server.
|
|
773
779
|
|
|
774
780
|
Args:
|
|
775
781
|
ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
|
|
776
782
|
argument (dict[str, str]): Arguments to pass to the completion request.
|
|
783
|
+
context_arguments (dict[str, Any] | None, optional): Optional context arguments to
|
|
784
|
+
include with the completion request. Defaults to None.
|
|
777
785
|
|
|
778
786
|
Returns:
|
|
779
787
|
mcp.types.Completion: The completion object.
|
|
@@ -781,7 +789,9 @@ class Client(Generic[ClientTransportT]):
|
|
|
781
789
|
Raises:
|
|
782
790
|
RuntimeError: If called while the client is not connected.
|
|
783
791
|
"""
|
|
784
|
-
result = await self.complete_mcp(
|
|
792
|
+
result = await self.complete_mcp(
|
|
793
|
+
ref=ref, argument=argument, context_arguments=context_arguments
|
|
794
|
+
)
|
|
785
795
|
return result.completion
|
|
786
796
|
|
|
787
797
|
# --- Tools ---
|
|
@@ -91,12 +91,12 @@ class MyComponent(MCPMixin):
|
|
|
91
91
|
# prompt
|
|
92
92
|
@mcp_prompt(name="A prompt")
|
|
93
93
|
def prompt_method(self, name):
|
|
94
|
-
return f"
|
|
94
|
+
return f"What's up {name}?"
|
|
95
95
|
|
|
96
96
|
# disabled prompt
|
|
97
97
|
@mcp_prompt(name="A prompt", enabled=False)
|
|
98
98
|
def prompt_method(self, name):
|
|
99
|
-
return f"
|
|
99
|
+
return f"What's up {name}?"
|
|
100
100
|
|
|
101
101
|
mcp_server = FastMCP()
|
|
102
102
|
component = MyComponent()
|
|
@@ -79,6 +79,7 @@ def _replace_ref_with_defs(
|
|
|
79
79
|
|
|
80
80
|
Examples:
|
|
81
81
|
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
|
82
|
+
- {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/..."}, "properties": {...}}
|
|
82
83
|
- {"$ref": "#/components/schemas/..."}
|
|
83
84
|
- {"items": {"$ref": "#/components/schemas/..."}}
|
|
84
85
|
- {"anyOf": [{"$ref": "#/components/schemas/..."}]}
|
|
@@ -117,6 +118,11 @@ def _replace_ref_with_defs(
|
|
|
117
118
|
for section in ["anyOf", "allOf", "oneOf"]:
|
|
118
119
|
for i, item in enumerate(schema.get(section, [])):
|
|
119
120
|
schema[section][i] = _replace_ref_with_defs(item)
|
|
121
|
+
if additionalProperties := schema.get("additionalProperties"):
|
|
122
|
+
if not isinstance(additionalProperties, bool):
|
|
123
|
+
schema["additionalProperties"] = _replace_ref_with_defs(
|
|
124
|
+
additionalProperties
|
|
125
|
+
)
|
|
120
126
|
if info.get("description", description) and not schema.get("description"):
|
|
121
127
|
schema["description"] = description
|
|
122
128
|
return schema
|
|
@@ -297,9 +303,11 @@ def _combine_schemas_and_map_params(
|
|
|
297
303
|
|
|
298
304
|
# Convert refs if needed
|
|
299
305
|
if convert_refs:
|
|
300
|
-
param_schema = _replace_ref_with_defs(param.schema_)
|
|
306
|
+
param_schema = _replace_ref_with_defs(param.schema_, param.description)
|
|
301
307
|
else:
|
|
302
|
-
param_schema = param.schema_
|
|
308
|
+
param_schema = param.schema_.copy()
|
|
309
|
+
if param.description and not param_schema.get("description"):
|
|
310
|
+
param_schema["description"] = param.description
|
|
303
311
|
original_desc = param_schema.get("description", "")
|
|
304
312
|
location_desc = f"({param.location.capitalize()} parameter)"
|
|
305
313
|
if original_desc:
|
|
@@ -324,9 +332,11 @@ def _combine_schemas_and_map_params(
|
|
|
324
332
|
|
|
325
333
|
# Convert refs if needed
|
|
326
334
|
if convert_refs:
|
|
327
|
-
param_schema = _replace_ref_with_defs(param.schema_)
|
|
335
|
+
param_schema = _replace_ref_with_defs(param.schema_, param.description)
|
|
328
336
|
else:
|
|
329
|
-
param_schema = param.schema_
|
|
337
|
+
param_schema = param.schema_.copy()
|
|
338
|
+
if param.description and not param_schema.get("description"):
|
|
339
|
+
param_schema["description"] = param.description
|
|
330
340
|
|
|
331
341
|
# Don't make optional parameters nullable - they can simply be omitted
|
|
332
342
|
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
@@ -344,7 +354,7 @@ def _combine_schemas_and_map_params(
|
|
|
344
354
|
if route.request_body.required:
|
|
345
355
|
required.append("body")
|
|
346
356
|
parameter_map["body"] = {"location": "body", "openapi_name": "body"}
|
|
347
|
-
|
|
357
|
+
elif body_props:
|
|
348
358
|
# Normal case: body has properties
|
|
349
359
|
for prop_name, prop_schema in body_props.items():
|
|
350
360
|
properties[prop_name] = prop_schema
|
|
@@ -357,6 +367,22 @@ def _combine_schemas_and_map_params(
|
|
|
357
367
|
|
|
358
368
|
if route.request_body.required:
|
|
359
369
|
required.extend(body_schema.get("required", []))
|
|
370
|
+
else:
|
|
371
|
+
# Handle direct array/primitive schemas (like list[str] parameters from FastAPI)
|
|
372
|
+
# Use the schema title as parameter name, fall back to generic name
|
|
373
|
+
param_name = body_schema.get("title", "body").lower()
|
|
374
|
+
|
|
375
|
+
# Clean the parameter name to be valid
|
|
376
|
+
import re
|
|
377
|
+
|
|
378
|
+
param_name = re.sub(r"[^a-zA-Z0-9_]", "_", param_name)
|
|
379
|
+
if not param_name or param_name[0].isdigit():
|
|
380
|
+
param_name = "body_data"
|
|
381
|
+
|
|
382
|
+
properties[param_name] = body_schema
|
|
383
|
+
if route.request_body.required:
|
|
384
|
+
required.append(param_name)
|
|
385
|
+
parameter_map[param_name] = {"location": "body", "openapi_name": param_name}
|
|
360
386
|
|
|
361
387
|
result = {
|
|
362
388
|
"type": "object",
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
|
-
from urllib.parse import urljoin
|
|
5
4
|
|
|
6
5
|
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
|
7
6
|
from mcp.server.auth.middleware.bearer_auth import (
|
|
@@ -146,8 +145,9 @@ class AuthProvider(TokenVerifierProtocol):
|
|
|
146
145
|
return None
|
|
147
146
|
|
|
148
147
|
if path:
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
prefix = str(self.base_url).rstrip("/")
|
|
149
|
+
suffix = path.lstrip("/")
|
|
150
|
+
return AnyHttpUrl(f"{prefix}/{suffix}")
|
|
151
151
|
return self.base_url
|
|
152
152
|
|
|
153
153
|
|
|
@@ -45,9 +45,11 @@ from starlette.requests import Request
|
|
|
45
45
|
from starlette.responses import RedirectResponse
|
|
46
46
|
from starlette.routing import Route
|
|
47
47
|
|
|
48
|
+
import fastmcp
|
|
48
49
|
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
|
49
50
|
from fastmcp.server.auth.redirect_validation import validate_redirect_uri
|
|
50
51
|
from fastmcp.utilities.logging import get_logger
|
|
52
|
+
from fastmcp.utilities.storage import JSONFileStorage, KVStorage
|
|
51
53
|
|
|
52
54
|
if TYPE_CHECKING:
|
|
53
55
|
pass
|
|
@@ -240,7 +242,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
240
242
|
token_verifier: TokenVerifier,
|
|
241
243
|
# FastMCP server configuration
|
|
242
244
|
base_url: AnyHttpUrl | str,
|
|
243
|
-
redirect_path: str =
|
|
245
|
+
redirect_path: str | None = None,
|
|
244
246
|
issuer_url: AnyHttpUrl | str | None = None,
|
|
245
247
|
service_documentation_url: AnyHttpUrl | str | None = None,
|
|
246
248
|
# Client redirect URI validation
|
|
@@ -254,6 +256,8 @@ class OAuthProxy(OAuthProvider):
|
|
|
254
256
|
extra_authorize_params: dict[str, str] | None = None,
|
|
255
257
|
# Extra parameters to forward to token endpoint
|
|
256
258
|
extra_token_params: dict[str, str] | None = None,
|
|
259
|
+
# Client storage
|
|
260
|
+
client_storage: KVStorage | None = None,
|
|
257
261
|
):
|
|
258
262
|
"""Initialize the OAuth proxy provider.
|
|
259
263
|
|
|
@@ -277,7 +281,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
277
281
|
valid_scopes: List of all the possible valid scopes for a client.
|
|
278
282
|
These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided.
|
|
279
283
|
forward_pkce: Whether to forward PKCE to upstream server (default True).
|
|
280
|
-
Enable for providers that support/require PKCE (Google, Azure, etc.).
|
|
284
|
+
Enable for providers that support/require PKCE (Google, Azure, AWS, etc.).
|
|
281
285
|
Disable only if upstream provider doesn't support PKCE.
|
|
282
286
|
token_endpoint_auth_method: Token endpoint authentication method for upstream server.
|
|
283
287
|
Common values: "client_secret_basic", "client_secret_post", "none".
|
|
@@ -287,6 +291,9 @@ class OAuthProxy(OAuthProvider):
|
|
|
287
291
|
Example: {"audience": "https://api.example.com"}
|
|
288
292
|
extra_token_params: Additional parameters to forward to the upstream token endpoint.
|
|
289
293
|
Useful for provider-specific parameters during token exchange.
|
|
294
|
+
client_storage: Storage implementation for OAuth client registrations.
|
|
295
|
+
Defaults to file-based storage in ~/.fastmcp/oauth-proxy-clients/ if not specified.
|
|
296
|
+
Pass any KVStorage implementation for custom storage backends.
|
|
290
297
|
"""
|
|
291
298
|
# Always enable DCR since we implement it locally for MCP clients
|
|
292
299
|
client_registration_options = ClientRegistrationOptions(
|
|
@@ -317,9 +324,12 @@ class OAuthProxy(OAuthProvider):
|
|
|
317
324
|
self._default_scope_str = " ".join(self.required_scopes or [])
|
|
318
325
|
|
|
319
326
|
# Store redirect configuration
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
327
|
+
if not redirect_path:
|
|
328
|
+
self._redirect_path = "/auth/callback"
|
|
329
|
+
else:
|
|
330
|
+
self._redirect_path = (
|
|
331
|
+
redirect_path if redirect_path.startswith("/") else f"/{redirect_path}"
|
|
332
|
+
)
|
|
323
333
|
self._allowed_client_redirect_uris = allowed_client_redirect_uris
|
|
324
334
|
|
|
325
335
|
# PKCE configuration
|
|
@@ -332,8 +342,13 @@ class OAuthProxy(OAuthProvider):
|
|
|
332
342
|
self._extra_authorize_params = extra_authorize_params or {}
|
|
333
343
|
self._extra_token_params = extra_token_params or {}
|
|
334
344
|
|
|
335
|
-
#
|
|
336
|
-
|
|
345
|
+
# Initialize client storage (default to file-based if not provided)
|
|
346
|
+
if client_storage is None:
|
|
347
|
+
cache_dir = fastmcp.settings.home / "oauth-proxy-clients"
|
|
348
|
+
client_storage = JSONFileStorage(cache_dir)
|
|
349
|
+
self._client_storage = client_storage
|
|
350
|
+
|
|
351
|
+
# Local state for token bookkeeping only (no client caching)
|
|
337
352
|
self._access_tokens: dict[str, AccessToken] = {}
|
|
338
353
|
self._refresh_tokens: dict[str, RefreshToken] = {}
|
|
339
354
|
|
|
@@ -384,9 +399,20 @@ class OAuthProxy(OAuthProvider):
|
|
|
384
399
|
|
|
385
400
|
For unregistered clients, returns None (which will raise an error in the SDK).
|
|
386
401
|
"""
|
|
387
|
-
|
|
402
|
+
# Load from storage
|
|
403
|
+
data = await self._client_storage.get(client_id)
|
|
404
|
+
if not data:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
if client_data := data.get("client", None):
|
|
408
|
+
return ProxyDCRClient(
|
|
409
|
+
allowed_redirect_uri_patterns=data.get(
|
|
410
|
+
"allowed_redirect_uri_patterns", self._allowed_client_redirect_uris
|
|
411
|
+
),
|
|
412
|
+
**client_data,
|
|
413
|
+
)
|
|
388
414
|
|
|
389
|
-
return
|
|
415
|
+
return None
|
|
390
416
|
|
|
391
417
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
392
418
|
"""Register a client locally
|
|
@@ -404,13 +430,17 @@ class OAuthProxy(OAuthProvider):
|
|
|
404
430
|
redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
|
|
405
431
|
grant_types=client_info.grant_types
|
|
406
432
|
or ["authorization_code", "refresh_token"],
|
|
407
|
-
scope=self._default_scope_str,
|
|
433
|
+
scope=client_info.scope or self._default_scope_str,
|
|
408
434
|
token_endpoint_auth_method="none",
|
|
409
435
|
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
|
|
410
436
|
)
|
|
411
437
|
|
|
412
|
-
# Store
|
|
413
|
-
|
|
438
|
+
# Store as structured dict with all needed metadata
|
|
439
|
+
storage_data = {
|
|
440
|
+
"client": proxy_client.model_dump(mode="json"),
|
|
441
|
+
"allowed_redirect_uri_patterns": self._allowed_client_redirect_uris,
|
|
442
|
+
}
|
|
443
|
+
await self._client_storage.set(client_info.client_id, storage_data)
|
|
414
444
|
|
|
415
445
|
# Log redirect URIs to help users discover what patterns they might need
|
|
416
446
|
if client_info.redirect_uris:
|