Merge ALLOWED_MCP_CLOUD_CLIENTS into a single ALLOWED_MCP_CLIENTS env var that supports both simple client IDs and pipe-separated client_id|redirect_uri entries. Enforce HTTPS for non-localhost redirect URIs, warn on malformed entries, and use wildcard scopes for all static clients (upstream IdP enforces actual scopes). Add deprecation warning for the old env var. Also fixes DCR proxy error messages to reference only ALLOWED_MCP_CLIENTS and use "Upstream" instead of "Nextcloud" for IdP-agnostic language. Enables Login Flow v2 + DCR on the mcp-keycloak docker-compose service. Adds 17 unit tests for ClientRegistry parsing/validation and 7 keycloak integration tests for DCR lifecycle, AS metadata, and client authorization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""
|
|
MCP Client Registry for ADR-004 Progressive Consent Architecture.
|
|
|
|
This module manages the registry of allowed MCP clients that can authenticate
|
|
via Flow 1. In production, this would integrate with Dynamic Client Registration
|
|
(DCR) or a database of pre-registered clients.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Dict, List, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class MCPClientInfo:
|
|
"""Information about a registered MCP client."""
|
|
|
|
client_id: str
|
|
name: str
|
|
redirect_uris: List[str]
|
|
allowed_scopes: List[str]
|
|
is_public: bool = True # Native clients are public (no client_secret)
|
|
metadata: Optional[Dict] = None
|
|
|
|
|
|
class ClientRegistry:
|
|
"""
|
|
Registry for MCP clients allowed to authenticate via Flow 1.
|
|
|
|
In production, this would:
|
|
1. Support Dynamic Client Registration (DCR) per RFC 7591
|
|
2. Integrate with IdP client registry
|
|
3. Store client metadata in database
|
|
4. Support client updates and revocation
|
|
"""
|
|
|
|
def __init__(self, allow_dynamic_registration: bool = False):
|
|
"""
|
|
Initialize the client registry.
|
|
|
|
Args:
|
|
allow_dynamic_registration: Whether to allow DCR for new clients
|
|
"""
|
|
self.allow_dynamic_registration = allow_dynamic_registration
|
|
self._clients: Dict[str, MCPClientInfo] = {}
|
|
self._load_static_clients()
|
|
|
|
def _load_static_clients(self):
|
|
"""Load statically configured clients from environment.
|
|
|
|
Format: comma-separated entries, each either:
|
|
- Simple client ID: gets localhost redirect URIs
|
|
- client_id|redirect_uri: gets the specified redirect URI
|
|
|
|
Redirect URI rules:
|
|
- http://localhost:* and http://127.0.0.1:* are allowed (native clients)
|
|
- https:// redirect URIs are allowed (cloud clients)
|
|
- http:// non-localhost redirect URIs are rejected with a warning
|
|
"""
|
|
# Deprecation warning for old env var
|
|
if os.getenv("ALLOWED_MCP_CLOUD_CLIENTS"):
|
|
logger.warning(
|
|
"ALLOWED_MCP_CLOUD_CLIENTS is deprecated. "
|
|
"Merge entries into ALLOWED_MCP_CLIENTS using the format: "
|
|
"client_id|https://redirect-uri"
|
|
)
|
|
|
|
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").strip()
|
|
|
|
if allowed_clients:
|
|
for entry in allowed_clients.split(","):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
|
|
if "|" in entry:
|
|
cid, redirect = entry.split("|", 1)
|
|
cid, redirect = cid.strip(), redirect.strip()
|
|
|
|
if not cid or not redirect:
|
|
logger.warning(
|
|
f"Skipping malformed ALLOWED_MCP_CLIENTS entry: {entry!r}"
|
|
)
|
|
continue
|
|
|
|
parsed = urlparse(redirect)
|
|
is_loopback = parsed.hostname in ("localhost", "127.0.0.1")
|
|
|
|
if parsed.scheme == "https":
|
|
pass # HTTPS always allowed
|
|
elif parsed.scheme == "http" and is_loopback:
|
|
pass # HTTP localhost allowed
|
|
else:
|
|
logger.warning(
|
|
f"Rejecting client {cid!r}: HTTP redirect URIs are only "
|
|
f"allowed for localhost, got {redirect!r}"
|
|
)
|
|
continue
|
|
|
|
self._clients[cid] = MCPClientInfo(
|
|
client_id=cid,
|
|
name=self._get_client_name(cid),
|
|
redirect_uris=[redirect],
|
|
allowed_scopes=["*"],
|
|
is_public=True,
|
|
)
|
|
logger.info(f"Registered static client: {cid}")
|
|
else:
|
|
self._clients[entry] = MCPClientInfo(
|
|
client_id=entry,
|
|
name=self._get_client_name(entry),
|
|
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
|
allowed_scopes=["*"],
|
|
is_public=True,
|
|
)
|
|
logger.info(f"Registered static client: {entry}")
|
|
|
|
# Add well-known clients if not explicitly configured
|
|
if not self._clients:
|
|
self._add_well_known_clients()
|
|
|
|
def _get_client_name(self, client_id: str) -> str:
|
|
"""Get human-readable name for client_id."""
|
|
known_names = {
|
|
"claude-desktop": "Claude Desktop",
|
|
"claude-ai": "Claude AI",
|
|
"continue-dev": "Continue IDE Extension",
|
|
"zed-editor": "Zed Editor",
|
|
"vscode-mcp": "VS Code MCP Extension",
|
|
"test-mcp-client": "Test MCP Client",
|
|
}
|
|
return known_names.get(client_id, client_id.replace("-", " ").title())
|
|
|
|
def _add_well_known_clients(self):
|
|
"""Add well-known MCP clients for testing and development."""
|
|
well_known = [
|
|
MCPClientInfo(
|
|
client_id="claude-desktop",
|
|
name="Claude Desktop",
|
|
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
|
allowed_scopes=["*"],
|
|
is_public=True,
|
|
metadata={"vendor": "Anthropic"},
|
|
),
|
|
MCPClientInfo(
|
|
client_id="test-mcp-client",
|
|
name="Test MCP Client",
|
|
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
|
allowed_scopes=["*"],
|
|
is_public=True,
|
|
metadata={"purpose": "testing"},
|
|
),
|
|
]
|
|
|
|
for client in well_known:
|
|
self._clients[client.client_id] = client
|
|
logger.info(f"Registered well-known client: {client.client_id}")
|
|
|
|
def validate_client(
|
|
self,
|
|
client_id: str,
|
|
redirect_uri: Optional[str] = None,
|
|
scopes: Optional[List[str]] = None,
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate a client_id and optionally its redirect_uri and scopes.
|
|
|
|
Args:
|
|
client_id: The client identifier to validate
|
|
redirect_uri: Optional redirect URI to validate
|
|
scopes: Optional list of scopes to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
# Check if client exists
|
|
client = self._clients.get(client_id)
|
|
if not client:
|
|
if self.allow_dynamic_registration:
|
|
# In production, would attempt DCR here
|
|
logger.info(f"Unknown client {client_id}, would attempt DCR")
|
|
return True, None
|
|
else:
|
|
return False, f"Unknown client_id: {client_id}"
|
|
|
|
# Validate redirect_uri if provided
|
|
if redirect_uri:
|
|
if not self._validate_redirect_uri(client, redirect_uri):
|
|
return False, f"Invalid redirect_uri for client {client_id}"
|
|
|
|
# Validate scopes if provided (wildcard "*" allows all scopes)
|
|
if scopes and "*" not in client.allowed_scopes:
|
|
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
|
if invalid_scopes:
|
|
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
|
|
|
return True, None
|
|
|
|
def _validate_redirect_uri(self, client: MCPClientInfo, redirect_uri: str) -> bool:
|
|
"""
|
|
Validate redirect_uri against client's registered URIs.
|
|
|
|
Args:
|
|
client: The client info
|
|
redirect_uri: The URI to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
# Parse the redirect URI
|
|
parsed = urlparse(redirect_uri)
|
|
|
|
# Check against registered patterns
|
|
for pattern in client.redirect_uris:
|
|
if "*" in pattern:
|
|
# Handle wildcard port (localhost:*)
|
|
pattern_base = pattern.replace(":*", "")
|
|
if redirect_uri.startswith(pattern_base + ":"):
|
|
# Validate it's localhost with a port
|
|
if parsed.hostname in ["localhost", "127.0.0.1"]:
|
|
return True
|
|
elif redirect_uri == pattern:
|
|
return True
|
|
|
|
return False
|
|
|
|
def register_client(self, client_info: MCPClientInfo) -> bool:
|
|
"""
|
|
Register a new MCP client (DCR support).
|
|
|
|
Args:
|
|
client_info: Client information to register
|
|
|
|
Returns:
|
|
True if registered successfully
|
|
"""
|
|
if not self.allow_dynamic_registration:
|
|
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
|
|
return False
|
|
|
|
if client_info.client_id in self._clients:
|
|
logger.warning(f"Client {client_info.client_id} already registered")
|
|
return False
|
|
|
|
self._clients[client_info.client_id] = client_info
|
|
logger.info(f"Dynamically registered client: {client_info.client_id}")
|
|
|
|
# In production, would persist to database
|
|
return True
|
|
|
|
def register_proxy_client(
|
|
self, client_id: str, redirect_uris: list[str], name: str = ""
|
|
) -> None:
|
|
"""Register a client discovered via DCR proxy.
|
|
|
|
When the MCP server acts as an OAuth AS proxy, clients register via
|
|
the proxy's /oauth/register endpoint. This method stores the client
|
|
locally so /oauth/authorize can validate it.
|
|
|
|
Args:
|
|
client_id: Client identifier from Nextcloud DCR response
|
|
redirect_uris: Allowed redirect URIs
|
|
name: Optional human-readable name
|
|
"""
|
|
self._clients[client_id] = MCPClientInfo(
|
|
client_id=client_id,
|
|
name=name or f"DCR-{client_id[:8]}",
|
|
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
|
|
allowed_scopes=["*"], # Nextcloud enforces actual scopes
|
|
is_public=True,
|
|
)
|
|
logger.info(f"Registered proxy client: {client_id}")
|
|
|
|
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
|
"""
|
|
Get client information.
|
|
|
|
Args:
|
|
client_id: The client identifier
|
|
|
|
Returns:
|
|
Client info if found, None otherwise
|
|
"""
|
|
return self._clients.get(client_id)
|
|
|
|
def list_clients(self) -> List[MCPClientInfo]:
|
|
"""
|
|
List all registered clients.
|
|
|
|
Returns:
|
|
List of client information
|
|
"""
|
|
return list(self._clients.values())
|
|
|
|
|
|
# Global registry instance
|
|
_registry: Optional[ClientRegistry] = None
|
|
|
|
|
|
def get_client_registry() -> ClientRegistry:
|
|
"""Get the global client registry instance."""
|
|
global _registry
|
|
if _registry is None:
|
|
# Check if DCR is enabled
|
|
allow_dcr = os.getenv("ENABLE_DCR", "false").lower() == "true"
|
|
_registry = ClientRegistry(allow_dynamic_registration=allow_dcr)
|
|
return _registry
|