- Document wildcard scope policy in ClientRegistry class docstring - Add hostname None guard and IPv6 loopback (::1) to redirect URI validation - Simplify redirect URI scheme validation into single guard clause - Add try/finally cleanup to DCR client deletion test - Validate 302 Location header in unknown client rejection test - Add unit tests for IPv6 loopback, malformed URIs, and DCR proxy paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
318 lines
11 KiB
Python
318 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
|
|
|
|
Scope Policy:
|
|
All clients are registered with allowed_scopes=["*"] (wildcard).
|
|
The MCP server acts as an OAuth AS proxy — it validates client
|
|
identity and redirect URIs locally, but delegates scope enforcement
|
|
to the upstream IdP (Nextcloud or Keycloak).
|
|
"""
|
|
|
|
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
|
|
"""
|
|
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)
|
|
hostname = parsed.hostname
|
|
if hostname is None:
|
|
logger.warning(
|
|
f"Skipping client {cid!r}: cannot parse hostname "
|
|
f"from {redirect!r}"
|
|
)
|
|
continue
|
|
is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
|
|
|
|
if not (
|
|
parsed.scheme == "https"
|
|
or (parsed.scheme == "http" and is_loopback)
|
|
):
|
|
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)
|
|
if not parsed.hostname:
|
|
return False
|
|
|
|
# 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", "::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
|