Skip to content

Commit 7d68025

Browse files
committed
feat: implement SEP-991 URL-based client ID (CIMD) support
Add support for Client ID Metadata Documents (CIMD) as defined in SEP-991. When a server advertises client_id_metadata_document_supported=true and the client provides a valid client_metadata_url, the URL is used as the client_id instead of performing dynamic client registration. Changes: - Add client_metadata_url parameter to OAuthClientProvider - Add helper functions in utils.py: is_valid_client_metadata_url, should_use_client_metadata_url, create_client_info_from_metadata_url - Update auth flow to use CIMD when conditions are met - Validate client_metadata_url at initialization time (must be HTTPS with non-root pathname) Github-Issue:#1538
1 parent 71c4755 commit 7d68025

File tree

2 files changed

+123
-12
lines changed

2 files changed

+123
-12
lines changed

src/mcp/client/auth/oauth2.py

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from mcp.client.auth.utils import (
2424
build_oauth_authorization_server_metadata_discovery_urls,
2525
build_protected_resource_metadata_discovery_urls,
26+
create_client_info_from_metadata_url,
2627
create_client_registration_request,
2728
create_oauth_metadata_request,
2829
extract_field_from_www_auth,
@@ -33,6 +34,8 @@
3334
handle_protected_resource_response,
3435
handle_registration_response,
3536
handle_token_response_scopes,
37+
is_valid_client_metadata_url,
38+
should_use_client_metadata_url,
3639
)
3740
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
3841
from mcp.shared.auth import (
@@ -96,6 +99,7 @@ class OAuthContext:
9699
redirect_handler: Callable[[str], Awaitable[None]] | None
97100
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None
98101
timeout: float = 300.0
102+
client_metadata_url: str | None = None # SEP-991: URL-based client ID
99103

100104
# Discovered metadata
101105
protected_resource_metadata: ProtectedResourceMetadata | None = None
@@ -226,15 +230,40 @@ def __init__(
226230
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
227231
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
228232
timeout: float = 300.0,
233+
client_metadata_url: str | None = None,
229234
):
230-
"""Initialize OAuth2 authentication."""
235+
"""Initialize OAuth2 authentication.
236+
237+
Args:
238+
server_url: The MCP server URL.
239+
client_metadata: OAuth client metadata for registration.
240+
storage: Token storage implementation.
241+
redirect_handler: Handler for authorization redirects.
242+
callback_handler: Handler for authorization callbacks.
243+
timeout: Timeout for the OAuth flow.
244+
client_metadata_url: SEP-991 URL-based client ID. When provided and the server
245+
advertises client_id_metadata_document_supported=true, this URL will be
246+
used as the client_id instead of performing dynamic client registration.
247+
Must be a valid HTTPS URL with a non-root pathname.
248+
249+
Raises:
250+
ValueError: If client_metadata_url is provided but not a valid HTTPS URL
251+
with a non-root pathname.
252+
"""
253+
# Validate client_metadata_url if provided
254+
if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url):
255+
raise ValueError(
256+
f"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {client_metadata_url}"
257+
)
258+
231259
self.context = OAuthContext(
232260
server_url=server_url,
233261
client_metadata=client_metadata,
234262
storage=storage,
235263
redirect_handler=redirect_handler,
236264
callback_handler=callback_handler,
237265
timeout=timeout,
266+
client_metadata_url=client_metadata_url,
238267
)
239268
self._initialized = False
240269

@@ -566,17 +595,30 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
566595
self.context.oauth_metadata,
567596
)
568597

569-
# Step 4: Register client if needed
570-
registration_request = create_client_registration_request(
571-
self.context.oauth_metadata,
572-
self.context.client_metadata,
573-
self.context.get_authorization_base_url(self.context.server_url),
574-
)
598+
# Step 4: Register client or use URL-based client ID (SEP-991)
575599
if not self.context.client_info:
576-
registration_response = yield registration_request
577-
client_information = await handle_registration_response(registration_response)
578-
self.context.client_info = client_information
579-
await self.context.storage.set_client_info(client_information)
600+
if should_use_client_metadata_url(
601+
self.context.oauth_metadata, self.context.client_metadata_url
602+
):
603+
# SEP-991: Use URL-based client ID
604+
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
605+
client_information = create_client_info_from_metadata_url(
606+
self.context.client_metadata_url, # type: ignore[arg-type]
607+
redirect_uris=self.context.client_metadata.redirect_uris,
608+
)
609+
self.context.client_info = client_information
610+
await self.context.storage.set_client_info(client_information)
611+
else:
612+
# Fallback to Dynamic Client Registration
613+
registration_request = create_client_registration_request(
614+
self.context.oauth_metadata,
615+
self.context.client_metadata,
616+
self.context.get_authorization_base_url(self.context.server_url),
617+
)
618+
registration_response = yield registration_request
619+
client_information = await handle_registration_response(registration_response)
620+
self.context.client_info = client_information
621+
await self.context.storage.set_client_info(client_information)
580622

581623
# Step 5: Perform authorization and complete token exchange
582624
token_response = yield await self._perform_authorization()

src/mcp/client/auth/utils.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from urllib.parse import urljoin, urlparse
44

55
from httpx import Request, Response
6-
from pydantic import ValidationError
6+
from pydantic import AnyUrl, ValidationError
77

88
from mcp.client.auth import OAuthRegistrationError, OAuthTokenError
99
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
@@ -243,6 +243,75 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
243243
raise OAuthRegistrationError(f"Invalid registration response: {e}")
244244

245245

246+
def is_valid_client_metadata_url(url: str | None) -> bool:
247+
"""Validate that a URL is suitable for use as a client_id (CIMD).
248+
249+
Per SEP-991, the URL must be HTTPS with a non-root pathname.
250+
251+
Args:
252+
url: The URL to validate
253+
254+
Returns:
255+
True if the URL is a valid HTTPS URL with a non-root pathname
256+
"""
257+
if not url:
258+
return False
259+
try:
260+
parsed = urlparse(url)
261+
return parsed.scheme == "https" and parsed.path not in ("", "/")
262+
except Exception:
263+
return False
264+
265+
266+
def should_use_client_metadata_url(
267+
oauth_metadata: OAuthMetadata | None,
268+
client_metadata_url: str | None,
269+
) -> bool:
270+
"""Determine if URL-based client ID (CIMD) should be used instead of DCR.
271+
272+
Per SEP-991, URL-based client IDs should be used when:
273+
1. The server advertises client_id_metadata_document_supported=true
274+
2. The client has a valid client_metadata_url configured
275+
276+
Args:
277+
oauth_metadata: OAuth authorization server metadata
278+
client_metadata_url: URL-based client ID (already validated)
279+
280+
Returns:
281+
True if CIMD should be used, False if DCR should be used
282+
"""
283+
if not client_metadata_url:
284+
return False
285+
286+
if not oauth_metadata:
287+
return False
288+
289+
return oauth_metadata.client_id_metadata_document_supported is True
290+
291+
292+
def create_client_info_from_metadata_url(
293+
client_metadata_url: str, redirect_uris: list[AnyUrl] | None = None
294+
) -> OAuthClientInformationFull:
295+
"""Create client information using a URL-based client ID (CIMD).
296+
297+
Per SEP-991, when using URL-based client IDs, the URL itself becomes the client_id
298+
and no client_secret is used (token_endpoint_auth_method="none").
299+
300+
Args:
301+
client_metadata_url: The URL to use as the client_id
302+
redirect_uris: The redirect URIs from the client metadata (passed through for
303+
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
304+
305+
Returns:
306+
OAuthClientInformationFull with the URL as client_id
307+
"""
308+
return OAuthClientInformationFull(
309+
client_id=client_metadata_url,
310+
token_endpoint_auth_method="none",
311+
redirect_uris=redirect_uris,
312+
)
313+
314+
246315
async def handle_token_response_scopes(
247316
response: Response,
248317
) -> OAuthToken:

0 commit comments

Comments
 (0)