feat: implement SEP-991 URL-based client ID (CIMD) support (#1652)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
Paul Carleton 2025-11-24 17:21:03 +00:00 • committed by GitHub
commit f22501315e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 476 additions and 14 deletions

View file

@ -150,9 +150,15 @@ class CallbackServer:
class SimpleAuthClient: class SimpleAuthClient:
"""Simple MCP client with auth support.""" """Simple MCP client with auth support."""
def __init__(self, server_url: str, transport_type: str = "streamable-http"): def __init__(
self,
server_url: str,
transport_type: str = "streamable-http",
client_metadata_url: str | None = None,
):
self.server_url = server_url self.server_url = server_url
self.transport_type = transport_type self.transport_type = transport_type
self.client_metadata_url = client_metadata_url
self.session: ClientSession | None = None self.session: ClientSession | None = None
async def connect(self): async def connect(self):
@ -185,12 +191,14 @@ class SimpleAuthClient:
webbrowser.open(authorization_url) webbrowser.open(authorization_url)
# Create OAuth authentication handler using the new interface # Create OAuth authentication handler using the new interface
# Use client_metadata_url to enable CIMD when the server supports it
oauth_auth = OAuthClientProvider( oauth_auth = OAuthClientProvider(
server_url=self.server_url, server_url=self.server_url,
client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
storage=InMemoryTokenStorage(), storage=InMemoryTokenStorage(),
redirect_handler=_default_redirect_handler, redirect_handler=_default_redirect_handler,
callback_handler=callback_handler, callback_handler=callback_handler,
client_metadata_url=self.client_metadata_url,
) )
# Create transport with auth handler based on transport type # Create transport with auth handler based on transport type
@ -334,6 +342,7 @@ async def main():
# Most MCP streamable HTTP servers use /mcp as the endpoint # Most MCP streamable HTTP servers use /mcp as the endpoint
server_url = os.getenv("MCP_SERVER_PORT", 8000) server_url = os.getenv("MCP_SERVER_PORT", 8000)
transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http")
client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL")
server_url = ( server_url = (
f"http://localhost:{server_url}/mcp" f"http://localhost:{server_url}/mcp"
if transport_type == "streamable-http" if transport_type == "streamable-http"
@ -343,9 +352,11 @@ async def main():
print("🚀 Simple MCP Auth Client") print("🚀 Simple MCP Auth Client")
print(f"Connecting to: {server_url}") print(f"Connecting to: {server_url}")
print(f"Transport type: {transport_type}") print(f"Transport type: {transport_type}")
if client_metadata_url:
print(f"Client metadata URL: {client_metadata_url}")
# Start connection flow - OAuth will be handled automatically # Start connection flow - OAuth will be handled automatically
client = SimpleAuthClient(server_url, transport_type) client = SimpleAuthClient(server_url, transport_type, client_metadata_url)
await client.connect() await client.connect()

View file

@ -23,6 +23,7 @@ from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, O
from mcp.client.auth.utils import ( from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls, build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls,
create_client_info_from_metadata_url,
create_client_registration_request, create_client_registration_request,
create_oauth_metadata_request, create_oauth_metadata_request,
extract_field_from_www_auth, extract_field_from_www_auth,
@ -33,6 +34,8 @@ from mcp.client.auth.utils import (
handle_protected_resource_response, handle_protected_resource_response,
handle_registration_response, handle_registration_response,
handle_token_response_scopes, handle_token_response_scopes,
is_valid_client_metadata_url,
should_use_client_metadata_url,
) )
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import ( from mcp.shared.auth import (
@ -96,6 +99,7 @@ class OAuthContext:
redirect_handler: Callable[[str], Awaitable[None]] | None redirect_handler: Callable[[str], Awaitable[None]] | None
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None
timeout: float = 300.0 timeout: float = 300.0
client_metadata_url: str | None = None
# Discovered metadata # Discovered metadata
protected_resource_metadata: ProtectedResourceMetadata | None = None protected_resource_metadata: ProtectedResourceMetadata | None = None
@ -226,8 +230,32 @@ class OAuthClientProvider(httpx.Auth):
redirect_handler: Callable[[str], Awaitable[None]] | None = None, redirect_handler: Callable[[str], Awaitable[None]] | None = None,
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
timeout: float = 300.0, timeout: float = 300.0,
client_metadata_url: str | None = None,
): ):
"""Initialize OAuth2 authentication.""" """Initialize OAuth2 authentication.
Args:
server_url: The MCP server URL.
client_metadata: OAuth client metadata for registration.
storage: Token storage implementation.
redirect_handler: Handler for authorization redirects.
callback_handler: Handler for authorization callbacks.
timeout: Timeout for the OAuth flow.
client_metadata_url: URL-based client ID. When provided and the server
advertises client_id_metadata_document_supported=true, this URL will be
used as the client_id instead of performing dynamic client registration.
Must be a valid HTTPS URL with a non-root pathname.
Raises:
ValueError: If client_metadata_url is provided but not a valid HTTPS URL
with a non-root pathname.
"""
# Validate client_metadata_url if provided
if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url):
raise ValueError(
f"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {client_metadata_url}"
)
self.context = OAuthContext( self.context = OAuthContext(
server_url=server_url, server_url=server_url,
client_metadata=client_metadata, client_metadata=client_metadata,
@ -235,6 +263,7 @@ class OAuthClientProvider(httpx.Auth):
redirect_handler=redirect_handler, redirect_handler=redirect_handler,
callback_handler=callback_handler, callback_handler=callback_handler,
timeout=timeout, timeout=timeout,
client_metadata_url=client_metadata_url,
) )
self._initialized = False self._initialized = False
@ -566,13 +595,26 @@ class OAuthClientProvider(httpx.Auth):
self.context.oauth_metadata, self.context.oauth_metadata,
) )
# Step 4: Register client if needed # Step 4: Register client or use URL-based client ID (CIMD)
if not self.context.client_info:
if should_use_client_metadata_url(
self.context.oauth_metadata, self.context.client_metadata_url
):
# Use URL-based client ID (CIMD)
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
client_information = create_client_info_from_metadata_url(
self.context.client_metadata_url, # type: ignore[arg-type]
redirect_uris=self.context.client_metadata.redirect_uris,
)
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)
else:
# Fallback to Dynamic Client Registration
registration_request = create_client_registration_request( registration_request = create_client_registration_request(
self.context.oauth_metadata, self.context.oauth_metadata,
self.context.client_metadata, self.context.client_metadata,
self.context.get_authorization_base_url(self.context.server_url), self.context.get_authorization_base_url(self.context.server_url),
) )
if not self.context.client_info:
registration_response = yield registration_request registration_response = yield registration_request
client_information = await handle_registration_response(registration_response) client_information = await handle_registration_response(registration_response)
self.context.client_info = client_information self.context.client_info = client_information

View file

@ -3,7 +3,7 @@ import re
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from httpx import Request, Response from httpx import Request, Response
from pydantic import ValidationError from pydantic import AnyUrl, ValidationError
from mcp.client.auth import OAuthRegistrationError, OAuthTokenError from mcp.client.auth import OAuthRegistrationError, OAuthTokenError
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
@ -243,6 +243,75 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
raise OAuthRegistrationError(f"Invalid registration response: {e}") raise OAuthRegistrationError(f"Invalid registration response: {e}")
def is_valid_client_metadata_url(url: str | None) -> bool:
"""Validate that a URL is suitable for use as a client_id (CIMD).
The URL must be HTTPS with a non-root pathname.
Args:
url: The URL to validate
Returns:
True if the URL is a valid HTTPS URL with a non-root pathname
"""
if not url:
return False
try:
parsed = urlparse(url)
return parsed.scheme == "https" and parsed.path not in ("", "/")
except Exception:
return False
def should_use_client_metadata_url(
oauth_metadata: OAuthMetadata | None,
client_metadata_url: str | None,
) -> bool:
"""Determine if URL-based client ID (CIMD) should be used instead of DCR.
URL-based client IDs should be used when:
1. The server advertises client_id_metadata_document_supported=true
2. The client has a valid client_metadata_url configured
Args:
oauth_metadata: OAuth authorization server metadata
client_metadata_url: URL-based client ID (already validated)
Returns:
True if CIMD should be used, False if DCR should be used
"""
if not client_metadata_url:
return False
if not oauth_metadata:
return False
return oauth_metadata.client_id_metadata_document_supported is True
def create_client_info_from_metadata_url(
client_metadata_url: str, redirect_uris: list[AnyUrl] | None = None
) -> OAuthClientInformationFull:
"""Create client information using a URL-based client ID (CIMD).
When using URL-based client IDs, the URL itself becomes the client_id
and no client_secret is used (token_endpoint_auth_method="none").
Args:
client_metadata_url: The URL to use as the client_id
redirect_uris: The redirect URIs from the client metadata (passed through for
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
Returns:
OAuthClientInformationFull with the URL as client_id
"""
return OAuthClientInformationFull(
client_id=client_metadata_url,
token_endpoint_auth_method="none",
redirect_uris=redirect_uris,
)
async def handle_token_response_scopes( async def handle_token_response_scopes(
response: Response, response: Response,
) -> OAuthToken: ) -> OAuthToken:

View file

@ -17,12 +17,16 @@ from mcp.client.auth import OAuthClientProvider, OAuthRegistrationError, PKCEPar
from mcp.client.auth.utils import ( from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls, build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls,
create_client_info_from_metadata_url,
create_client_registration_request,
create_oauth_metadata_request, create_oauth_metadata_request,
extract_field_from_www_auth, extract_field_from_www_auth,
extract_resource_metadata_from_www_auth, extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth, extract_scope_from_www_auth,
get_client_metadata_scopes, get_client_metadata_scopes,
handle_registration_response, handle_registration_response,
is_valid_client_metadata_url,
should_use_client_metadata_url,
) )
from mcp.shared.auth import ( from mcp.shared.auth import (
OAuthClientInformationFull, OAuthClientInformationFull,
@ -945,6 +949,49 @@ class TestRegistrationResponse:
assert "Registration failed: 400" in str(exc_info.value) assert "Registration failed: 400" in str(exc_info.value)
class TestCreateClientRegistrationRequest:
"""Test client registration request creation."""
def test_uses_registration_endpoint_from_metadata(self):
"""Test that registration URL comes from metadata when available."""
oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
registration_endpoint=AnyHttpUrl("https://auth.example.com/register"),
)
client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")])
request = create_client_registration_request(oauth_metadata, client_metadata, "https://auth.example.com")
assert str(request.url) == "https://auth.example.com/register"
assert request.method == "POST"
def test_falls_back_to_default_register_endpoint_when_no_metadata(self):
"""Test that registration uses fallback URL when auth_server_metadata is None."""
client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")])
request = create_client_registration_request(None, client_metadata, "https://auth.example.com")
assert str(request.url) == "https://auth.example.com/register"
assert request.method == "POST"
def test_falls_back_when_metadata_has_no_registration_endpoint(self):
"""Test fallback when metadata exists but lacks registration_endpoint."""
oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
# No registration_endpoint
)
client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")])
request = create_client_registration_request(oauth_metadata, client_metadata, "https://auth.example.com")
assert str(request.url) == "https://auth.example.com/register"
assert request.method == "POST"
class TestAuthFlow: class TestAuthFlow:
"""Test the auth flow in httpx.""" """Test the auth flow in httpx."""
@ -1783,3 +1830,296 @@ class TestWWWAuthenticate:
result = extract_field_from_www_auth(init_response, field_name) result = extract_field_from_www_auth(init_response, field_name)
assert result is None, f"Should return None for {description}" assert result is None, f"Should return None for {description}"
class TestCIMD:
"""Test Client ID Metadata Document (CIMD) support."""
@pytest.mark.parametrize(
"url,expected",
[
# Valid CIMD URLs
("https://example.com/client", True),
("https://example.com/client-metadata.json", True),
("https://example.com/path/to/client", True),
("https://example.com:8443/client", True),
# Invalid URLs - HTTP (not HTTPS)
("http://example.com/client", False),
# Invalid URLs - root path
("https://example.com", False),
("https://example.com/", False),
# Invalid URLs - None or empty
(None, False),
("", False),
# Invalid URLs - malformed (triggers urlparse exception)
("http://[::1/foo/", False),
],
)
def test_is_valid_client_metadata_url(self, url: str | None, expected: bool):
"""Test CIMD URL validation."""
assert is_valid_client_metadata_url(url) == expected
def test_should_use_client_metadata_url_when_server_supports(self):
"""Test that CIMD is used when server supports it and URL is provided."""
oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
client_id_metadata_document_supported=True,
)
assert should_use_client_metadata_url(oauth_metadata, "https://example.com/client") is True
def test_should_not_use_client_metadata_url_when_server_does_not_support(self):
"""Test that CIMD is not used when server doesn't support it."""
oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
client_id_metadata_document_supported=False,
)
assert should_use_client_metadata_url(oauth_metadata, "https://example.com/client") is False
def test_should_not_use_client_metadata_url_when_not_provided(self):
"""Test that CIMD is not used when no URL is provided."""
oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
client_id_metadata_document_supported=True,
)
assert should_use_client_metadata_url(oauth_metadata, None) is False
def test_should_not_use_client_metadata_url_when_no_metadata(self):
"""Test that CIMD is not used when OAuth metadata is None."""
assert should_use_client_metadata_url(None, "https://example.com/client") is False
def test_create_client_info_from_metadata_url(self):
"""Test creating client info from CIMD URL."""
client_info = create_client_info_from_metadata_url(
"https://example.com/client",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
assert client_info.client_id == "https://example.com/client"
assert client_info.token_endpoint_auth_method == "none"
assert client_info.redirect_uris == [AnyUrl("http://localhost:3030/callback")]
assert client_info.client_secret is None
def test_oauth_provider_with_valid_client_metadata_url(
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
):
"""Test OAuthClientProvider initialization with valid client_metadata_url."""
async def redirect_handler(url: str) -> None:
pass # pragma: no cover
async def callback_handler() -> tuple[str, str | None]:
return "test_auth_code", "test_state" # pragma: no cover
provider = OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
client_metadata_url="https://example.com/client",
)
assert provider.context.client_metadata_url == "https://example.com/client"
def test_oauth_provider_with_invalid_client_metadata_url_raises_error(
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
):
"""Test OAuthClientProvider raises error for invalid client_metadata_url."""
async def redirect_handler(url: str) -> None:
pass # pragma: no cover
async def callback_handler() -> tuple[str, str | None]:
return "test_auth_code", "test_state" # pragma: no cover
with pytest.raises(ValueError) as exc_info:
OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
client_metadata_url="http://example.com/client", # HTTP instead of HTTPS
)
assert "HTTPS URL with a non-root pathname" in str(exc_info.value)
@pytest.mark.anyio
async def test_auth_flow_uses_cimd_when_server_supports(
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
):
"""Test that auth flow uses CIMD URL as client_id when server supports it."""
async def redirect_handler(url: str) -> None:
pass # pragma: no cover
async def callback_handler() -> tuple[str, str | None]:
return "test_auth_code", "test_state" # pragma: no cover
provider = OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
client_metadata_url="https://example.com/client",
)
provider.context.current_tokens = None
provider.context.token_expiry_time = None
provider._initialized = True
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request
request = await auth_flow.__anext__()
assert "Authorization" not in request.headers
# Send 401 response
response = httpx.Response(401, headers={}, request=test_request)
# PRM discovery
prm_request = await auth_flow.asend(response)
prm_response = httpx.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=prm_request,
)
# OAuth metadata discovery
oauth_request = await auth_flow.asend(prm_response)
oauth_response = httpx.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
b'"authorization_endpoint": "https://auth.example.com/authorize", '
b'"token_endpoint": "https://auth.example.com/token", '
b'"client_id_metadata_document_supported": true}'
),
request=oauth_request,
)
# Mock authorization
provider._perform_authorization_code_grant = mock.AsyncMock(
return_value=("test_auth_code", "test_code_verifier")
)
# Should skip DCR and go directly to token exchange
token_request = await auth_flow.asend(oauth_response)
assert token_request.method == "POST"
assert str(token_request.url) == "https://auth.example.com/token"
# Verify client_id is the CIMD URL
content = token_request.content.decode()
assert "client_id=https%3A%2F%2Fexample.com%2Fclient" in content
# Verify client info was set correctly
assert provider.context.client_info is not None
assert provider.context.client_info.client_id == "https://example.com/client"
assert provider.context.client_info.token_endpoint_auth_method == "none"
# Complete the flow
token_response = httpx.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
)
final_request = await auth_flow.asend(token_response)
assert final_request.headers["Authorization"] == "Bearer test_token"
final_response = httpx.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
pass
@pytest.mark.anyio
async def test_auth_flow_falls_back_to_dcr_when_no_cimd_support(
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
):
"""Test that auth flow falls back to DCR when server doesn't support CIMD."""
async def redirect_handler(url: str) -> None:
pass # pragma: no cover
async def callback_handler() -> tuple[str, str | None]:
return "test_auth_code", "test_state" # pragma: no cover
provider = OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
client_metadata_url="https://example.com/client",
)
provider.context.current_tokens = None
provider.context.token_expiry_time = None
provider._initialized = True
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request
await auth_flow.__anext__()
# Send 401 response
response = httpx.Response(401, headers={}, request=test_request)
# PRM discovery
prm_request = await auth_flow.asend(response)
prm_response = httpx.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=prm_request,
)
# OAuth metadata discovery - server does NOT support CIMD
oauth_request = await auth_flow.asend(prm_response)
oauth_response = httpx.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
b'"authorization_endpoint": "https://auth.example.com/authorize", '
b'"token_endpoint": "https://auth.example.com/token", '
b'"registration_endpoint": "https://auth.example.com/register"}'
),
request=oauth_request,
)
# Should proceed to DCR instead of skipping it
registration_request = await auth_flow.asend(oauth_response)
assert registration_request.method == "POST"
assert str(registration_request.url) == "https://auth.example.com/register"
# Complete the flow to avoid generator cleanup issues
registration_response = httpx.Response(
201,
content=b'{"client_id": "dcr_client_id", "redirect_uris": ["http://localhost:3030/callback"]}',
request=registration_request,
)
# Mock authorization
provider._perform_authorization_code_grant = mock.AsyncMock(
return_value=("test_auth_code", "test_code_verifier")
)
token_request = await auth_flow.asend(registration_response)
token_response = httpx.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
)
final_request = await auth_flow.asend(token_response)
final_response = httpx.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
pass