fastmcp 2.12.3__py3-none-any.whl → 2.12.5__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 (34) hide show
  1. fastmcp/cli/install/gemini_cli.py +0 -1
  2. fastmcp/cli/run.py +2 -2
  3. fastmcp/client/auth/oauth.py +49 -36
  4. fastmcp/client/client.py +12 -2
  5. fastmcp/contrib/mcp_mixin/README.md +2 -2
  6. fastmcp/experimental/utilities/openapi/schemas.py +31 -5
  7. fastmcp/server/auth/auth.py +3 -3
  8. fastmcp/server/auth/oauth_proxy.py +42 -12
  9. fastmcp/server/auth/oidc_proxy.py +348 -0
  10. fastmcp/server/auth/providers/auth0.py +174 -0
  11. fastmcp/server/auth/providers/aws.py +237 -0
  12. fastmcp/server/auth/providers/azure.py +6 -2
  13. fastmcp/server/auth/providers/descope.py +172 -0
  14. fastmcp/server/auth/providers/github.py +6 -2
  15. fastmcp/server/auth/providers/google.py +6 -2
  16. fastmcp/server/auth/providers/workos.py +6 -2
  17. fastmcp/server/context.py +7 -6
  18. fastmcp/server/http.py +1 -1
  19. fastmcp/server/middleware/logging.py +147 -116
  20. fastmcp/server/middleware/middleware.py +3 -2
  21. fastmcp/server/openapi.py +5 -1
  22. fastmcp/server/server.py +36 -31
  23. fastmcp/settings.py +27 -5
  24. fastmcp/tools/tool.py +4 -2
  25. fastmcp/utilities/json_schema.py +18 -1
  26. fastmcp/utilities/logging.py +66 -4
  27. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +2 -1
  28. fastmcp/utilities/storage.py +204 -0
  29. fastmcp/utilities/tests.py +8 -6
  30. {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/METADATA +121 -48
  31. {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/RECORD +34 -29
  32. {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/WHEEL +0 -0
  33. {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/entry_points.txt +0 -0
  34. {fastmcp-2.12.3.dist-info → fastmcp-2.12.5.dist-info}/licenses/LICENSE +0 -0
@@ -104,7 +104,6 @@ def install_gemini_cli(
104
104
  )
105
105
  return False
106
106
 
107
- # Build uv run command using Environment.build_uv_run_command()
108
107
  env_config = UVEnvironment(
109
108
  python=python_version,
110
109
  dependencies=(with_packages or []) + ["fastmcp"],
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
- # Note: log_level is not currently supported by run_async
188
- # TODO: Add log_level support to server.run_async
187
+ if log_level:
188
+ kwargs["log_level"] = log_level
189
189
 
190
190
  if not show_banner:
191
191
  kwargs["show_banner"] = False
@@ -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
- self.cache_dir = cache_dir or default_cache_dir()
71
- self.cache_dir.mkdir(exist_ok=True, parents=True)
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 get_cache_key(self) -> str:
80
- """Generate a safe filesystem key from the server's base URL."""
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
- key = self.get_cache_key()
92
- return self.cache_dir / f"{key}_{file_type}.json"
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
- path = self._get_file_path("tokens")
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 JSON and validate as StoredToken
100
- stored = stored_token_adapter.validate_json(path.read_text())
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 (FileNotFoundError, ValidationError) as e:
126
+ except ValidationError as e:
121
127
  logger.debug(
122
- f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
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
- path = self._get_file_path("tokens")
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 Pydantic serialization
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
- path = self._get_file_path("client_info")
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.model_validate_json(
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
- client_info_path = self._get_file_path("client_info")
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 (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
173
+ except ValidationError as e:
166
174
  logger.debug(
167
- f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
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
- path = self._get_file_path("client_info")
174
- path.write_text(client_info.model_dump_json(indent=2))
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
- # For any non-redirect response, something is wrong
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(ref=ref, argument=argument)
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(ref=ref, argument=argument)
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"Whats up {name}?"
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"Whats up {name}?"
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
- else:
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",
@@ -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
- return AnyHttpUrl(urljoin(str(self.base_url), path))
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 = "/auth/callback",
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
- self._redirect_path = (
321
- redirect_path if redirect_path.startswith("/") else f"/{redirect_path}"
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
- # Local state for DCR and token bookkeeping
336
- self._clients: dict[str, OAuthClientInformationFull] = {}
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
- client = self._clients.get(client_id)
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 client
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 the ProxyDCRClient
413
- self._clients[client_info.client_id] = proxy_client
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: