Skip to content

Commit f225013

Browse files
pcarletonclaudefelixweinberger
authored
feat: implement SEP-991 URL-based client ID (CIMD) support (#1652)
Co-authored-by: Claude <[email protected]> Co-authored-by: Felix Weinberger <[email protected]>
1 parent f2517fe commit f225013

File tree

4 files changed

+476
-14
lines changed

4 files changed

+476
-14
lines changed

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,15 @@ def get_state(self):
150150
class SimpleAuthClient:
151151
"""Simple MCP client with auth support."""
152152

153-
def __init__(self, server_url: str, transport_type: str = "streamable-http"):
153+
def __init__(
154+
self,
155+
server_url: str,
156+
transport_type: str = "streamable-http",
157+
client_metadata_url: str | None = None,
158+
):
154159
self.server_url = server_url
155160
self.transport_type = transport_type
161+
self.client_metadata_url = client_metadata_url
156162
self.session: ClientSession | None = None
157163

158164
async def connect(self):
@@ -185,12 +191,14 @@ async def _default_redirect_handler(authorization_url: str) -> None:
185191
webbrowser.open(authorization_url)
186192

187193
# Create OAuth authentication handler using the new interface
194+
# Use client_metadata_url to enable CIMD when the server supports it
188195
oauth_auth = OAuthClientProvider(
189196
server_url=self.server_url,
190197
client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
191198
storage=InMemoryTokenStorage(),
192199
redirect_handler=_default_redirect_handler,
193200
callback_handler=callback_handler,
201+
client_metadata_url=self.client_metadata_url,
194202
)
195203

196204
# Create transport with auth handler based on transport type
@@ -334,6 +342,7 @@ async def main():
334342
# Most MCP streamable HTTP servers use /mcp as the endpoint
335343
server_url = os.getenv("MCP_SERVER_PORT", 8000)
336344
transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http")
345+
client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL")
337346
server_url = (
338347
f"http://localhost:{server_url}/mcp"
339348
if transport_type == "streamable-http"
@@ -343,9 +352,11 @@ async def main():
343352
print("🚀 Simple MCP Auth Client")
344353
print(f"Connecting to: {server_url}")
345354
print(f"Transport type: {transport_type}")
355+
if client_metadata_url:
356+
print(f"Client metadata URL: {client_metadata_url}")
346357

347358
# Start connection flow - OAuth will be handled automatically
348-
client = SimpleAuthClient(server_url, transport_type)
359+
client = SimpleAuthClient(server_url, transport_type, client_metadata_url)
349360
await client.connect()
350361

351362

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
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: 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 (CIMD)
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+
# Use URL-based client ID (CIMD)
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+
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+
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+
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)